Skip to content

API Reference

Auto-generated API documentation for the harombe Python package. This reference is generated from docstrings in the source code using mkdocstrings.

Core Modules

Agent

The ReAct agent loop and runtime for autonomous task execution.

harombe.agent

ReAct agent loop for autonomous task execution.

This module implements the Reasoning + Acting (ReAct) pattern where the agent alternates between reasoning about what to do and executing tool calls. The loop continues until the task is complete or the step limit is reached.

Usage::

from harombe.agent.loop import Agent
from harombe.llm.ollama import OllamaClient
from harombe.tools.registry import get_enabled_tools

llm = OllamaClient(model="qwen2.5:7b")
tools = get_enabled_tools(shell=True, filesystem=True)
agent = Agent(llm=llm, tools=tools)
response = await agent.run("Analyze this file")

DelegationContext dataclass

Tracks the delegation chain from root agent to current agent.

Enforces max_depth and detects cycles (no agent name may appear twice in the chain).

Source code in src/harombe/agent/delegation.py
@dataclass
class DelegationContext:
    """Tracks the delegation chain from root agent to current agent.

    Enforces max_depth and detects cycles (no agent name may appear
    twice in the chain).
    """

    chain: list[str] = field(default_factory=list)
    max_depth: int = 3

    @property
    def depth(self) -> int:
        """Current delegation depth."""
        return len(self.chain)

    def can_delegate(self, target_name: str) -> tuple[bool, str]:
        """Check if delegation to the target agent is allowed.

        Args:
            target_name: Name of the agent to delegate to

        Returns:
            Tuple of (allowed, reason_string)
        """
        if self.depth >= self.max_depth:
            return False, f"Maximum delegation depth ({self.max_depth}) reached"

        if target_name in self.chain:
            return False, f"Cycle detected: '{target_name}' already in chain {self.chain}"

        return True, ""

    def child_context(self, agent_name: str) -> DelegationContext:
        """Create a child context for a delegated agent.

        Args:
            agent_name: Name of the current agent being delegated to

        Returns:
            New DelegationContext with extended chain
        """
        return DelegationContext(
            chain=[*self.chain, agent_name],
            max_depth=self.max_depth,
        )

depth property

Current delegation depth.

can_delegate(target_name)

Check if delegation to the target agent is allowed.

Parameters:

Name Type Description Default
target_name str

Name of the agent to delegate to

required

Returns:

Type Description
tuple[bool, str]

Tuple of (allowed, reason_string)

Source code in src/harombe/agent/delegation.py
def can_delegate(self, target_name: str) -> tuple[bool, str]:
    """Check if delegation to the target agent is allowed.

    Args:
        target_name: Name of the agent to delegate to

    Returns:
        Tuple of (allowed, reason_string)
    """
    if self.depth >= self.max_depth:
        return False, f"Maximum delegation depth ({self.max_depth}) reached"

    if target_name in self.chain:
        return False, f"Cycle detected: '{target_name}' already in chain {self.chain}"

    return True, ""

child_context(agent_name)

Create a child context for a delegated agent.

Parameters:

Name Type Description Default
agent_name str

Name of the current agent being delegated to

required

Returns:

Type Description
DelegationContext

New DelegationContext with extended chain

Source code in src/harombe/agent/delegation.py
def child_context(self, agent_name: str) -> DelegationContext:
    """Create a child context for a delegated agent.

    Args:
        agent_name: Name of the current agent being delegated to

    Returns:
        New DelegationContext with extended chain
    """
    return DelegationContext(
        chain=[*self.chain, agent_name],
        max_depth=self.max_depth,
    )

Agent

ReAct agent with tool calling capabilities.

Source code in src/harombe/agent/loop.py
class Agent:
    """ReAct agent with tool calling capabilities."""

    def __init__(
        self,
        llm: LLMClient,
        tools: list[Tool],
        max_steps: int = 10,
        system_prompt: str = "You are a helpful AI assistant.",
        confirm_dangerous: bool = True,
        confirm_callback: Callable[[str, str, dict[str, Any]], bool] | None = None,
        cluster_manager: Optional["ClusterManager"] = None,
        memory_manager: Optional["MemoryManager"] = None,
        mcp_manager: Optional["MCPManager"] = None,
        session_id: str | None = None,
        enable_rag: bool = False,
        rag_top_k: int = 5,
        rag_min_similarity: float = 0.7,
    ):
        """Initialize the agent.

        Args:
            llm: LLM client for generating responses
            tools: List of available tools
            max_steps: Maximum reasoning steps before forcing final answer
            system_prompt: System prompt for the agent
            confirm_dangerous: Whether to require confirmation for dangerous tools
            confirm_callback: Function called for dangerous tool confirmation.
                             Takes (tool_name, description, args) -> bool.
                             If None and confirm_dangerous=True, auto-denies dangerous tools.
            cluster_manager: Optional cluster manager for distributed routing
            memory_manager: Optional memory manager for conversation persistence
            mcp_manager: Optional MCP manager for external tool servers
            session_id: Session ID for loading/saving conversation history
            enable_rag: Enable retrieval-augmented generation from semantic memory
            rag_top_k: Number of similar messages to retrieve for RAG
            rag_min_similarity: Minimum similarity threshold for RAG retrieval
        """
        self.llm = llm
        self.tools = {tool.schema.name: tool for tool in tools}
        # Merge MCP tools into the tool dict
        if mcp_manager:
            self.tools.update(mcp_manager.get_all_tools())
        self.max_steps = max_steps
        self.system_prompt = system_prompt
        self.confirm_dangerous = confirm_dangerous
        self.confirm_callback = confirm_callback
        self.cluster_manager = cluster_manager
        self.memory_manager = memory_manager
        self.session_id = session_id
        self.enable_rag = enable_rag
        self.rag_top_k = rag_top_k
        self.rag_min_similarity = rag_min_similarity

        # Build tool schemas for LLM (from merged tool dict, includes MCP tools)
        self.tool_schemas = [t.schema.to_openai_format() for t in self.tools.values()]

    async def run(self, user_message: str) -> str:
        """Run the agent on a user message.

        Args:
            user_message: User's input message

        Returns:
            Agent's final response
        """
        # Initialize state
        state = AgentState(self.system_prompt)

        # Load history if memory is enabled
        if self.memory_manager and self.session_id:
            history = self.memory_manager.load_history(self.session_id)
            # Replace system message with loaded history (which includes system prompt)
            if history:
                state.messages = history

        # Retrieve relevant context if RAG is enabled
        rag_context = None
        if self.enable_rag and self.memory_manager:
            rag_context = await self._retrieve_rag_context(user_message)

        # Add user message (with RAG context if available)
        if rag_context:
            # Inject RAG context into user message
            enhanced_message = self._format_message_with_context(user_message, rag_context)
            state.add_user_message(enhanced_message)
        else:
            state.add_user_message(user_message)

        # Save original user message to memory (without RAG context)
        if self.memory_manager and self.session_id:
            original_msg = Message(role="user", content=user_message)
            self.memory_manager.save_message(self.session_id, original_msg)

        # Use smart routing if cluster manager is available
        llm_client = await self._get_llm_client(user_message, state.messages)

        for step in range(1, self.max_steps + 1):
            # Get LLM response
            response = await llm_client.complete(
                messages=state.messages,
                tools=self.tool_schemas if step < self.max_steps else None,
            )

            # If no tool calls, this is the final answer
            if not response.tool_calls:
                # Save final assistant message
                if self.memory_manager and self.session_id:
                    final_msg = Message(role="assistant", content=response.content)
                    self.memory_manager.save_message(self.session_id, final_msg)
                return response.content

            # Add assistant response with tool calls
            state.add_assistant_message(response)

            # Save assistant message with tool calls
            if self.memory_manager and self.session_id:
                self.memory_manager.save_message(self.session_id, state.messages[-1])

            # Execute each tool call
            for tool_call in response.tool_calls:
                result = await self._execute_tool_call(tool_call)
                state.add_tool_result(tool_call.id, tool_call.name, result)

                # Save tool result
                if self.memory_manager and self.session_id:
                    self.memory_manager.save_message(self.session_id, state.messages[-1])

        # Max steps reached - force final answer
        final_response = await llm_client.complete(
            messages=state.messages,
            tools=None,  # No tools available - must give final answer
        )

        # Save final assistant message
        if self.memory_manager and self.session_id:
            final_msg = Message(role="assistant", content=final_response.content)
            self.memory_manager.save_message(self.session_id, final_msg)

        return final_response.content

    async def _get_llm_client(
        self,
        query: str,
        messages: list[Message],
    ) -> LLMClient:
        """
        Get appropriate LLM client, using smart routing if cluster is available.

        Args:
            query: User query
            messages: Conversation history

        Returns:
            LLM client to use
        """
        if self.cluster_manager is None:
            return self.llm

        # Use smart routing to select appropriate node
        node, _decision = self.cluster_manager.select_node_smart(
            query=query,
            context=messages,
            fallback=True,
        )

        if node is None:
            # No nodes available, fallback to local LLM
            return self.llm

        # Get client for selected node
        client = self.cluster_manager.get_client(node.name)
        if client is None:
            # Client not available, fallback to local LLM
            return self.llm

        return client

    async def _execute_tool_call(self, tool_call: ToolCall) -> str:
        """Execute a tool call with optional confirmation for dangerous tools.

        Args:
            tool_call: The tool call to execute

        Returns:
            Tool execution result or cancellation message
        """
        tool_name = tool_call.name

        # Check if tool exists
        if tool_name not in self.tools:
            return f"Error: Unknown tool '{tool_name}'"

        tool = self.tools[tool_name]

        # Check for dangerous tool confirmation
        if self.confirm_dangerous and tool.schema.dangerous:
            if self.confirm_callback is None:
                # No callback provided - auto-deny dangerous tools
                return f"[CANCELLED] Tool '{tool_name}' requires user confirmation"

            # Ask user for confirmation
            confirmed = self.confirm_callback(
                tool_name,
                tool.schema.description,
                tool_call.arguments,
            )

            if not confirmed:
                return f"[CANCELLED] User declined to execute '{tool_name}'"

        # Execute the tool
        try:
            result = await tool.execute(**tool_call.arguments)
            return result
        except TypeError as e:
            return f"Error: Invalid arguments for tool '{tool_name}': {e}"
        except Exception as e:
            return f"Error executing tool '{tool_name}': {e}"

    async def _retrieve_rag_context(self, query: str) -> list[Message]:
        """Retrieve relevant context from semantic memory for RAG.

        Args:
            query: User query to find relevant context for

        Returns:
            List of relevant messages from conversation history
        """
        if not self.memory_manager or not self.memory_manager.semantic_search_enabled:
            return []

        try:
            # Search for similar messages across all sessions
            relevant_messages = await self.memory_manager.search_similar(
                query=query,
                top_k=self.rag_top_k,
                session_id=None,  # Search across all sessions
                min_similarity=self.rag_min_similarity,
            )
            return relevant_messages
        except Exception:
            # Silently fail - don't break agent execution
            return []

    def _format_message_with_context(self, user_message: str, context: list[Message]) -> str:
        """Format user message with RAG context.

        Args:
            user_message: Original user message
            context: Relevant messages from conversation history

        Returns:
            Enhanced message with context
        """
        if not context:
            return user_message

        # Build context section
        context_lines = ["RELEVANT CONTEXT FROM PAST CONVERSATIONS:", "---"]

        for msg in context:
            role_label = msg.role.upper()
            # Truncate long messages
            content = msg.content
            if len(content) > 200:
                content = content[:200] + "..."
            context_lines.append(f"[{role_label}]: {content}")

        context_lines.append("---")
        context_lines.append("")
        context_lines.append(
            "Now answer the current user question using the context above if relevant."
        )
        context_lines.append("")
        context_lines.append(f"USER QUESTION: {user_message}")

        return "\n".join(context_lines)

__init__(llm, tools, max_steps=10, system_prompt='You are a helpful AI assistant.', confirm_dangerous=True, confirm_callback=None, cluster_manager=None, memory_manager=None, mcp_manager=None, session_id=None, enable_rag=False, rag_top_k=5, rag_min_similarity=0.7)

Initialize the agent.

Parameters:

Name Type Description Default
llm LLMClient

LLM client for generating responses

required
tools list[Tool]

List of available tools

required
max_steps int

Maximum reasoning steps before forcing final answer

10
system_prompt str

System prompt for the agent

'You are a helpful AI assistant.'
confirm_dangerous bool

Whether to require confirmation for dangerous tools

True
confirm_callback Callable[[str, str, dict[str, Any]], bool] | None

Function called for dangerous tool confirmation. Takes (tool_name, description, args) -> bool. If None and confirm_dangerous=True, auto-denies dangerous tools.

None
cluster_manager Optional[ClusterManager]

Optional cluster manager for distributed routing

None
memory_manager Optional[MemoryManager]

Optional memory manager for conversation persistence

None
mcp_manager Optional[MCPManager]

Optional MCP manager for external tool servers

None
session_id str | None

Session ID for loading/saving conversation history

None
enable_rag bool

Enable retrieval-augmented generation from semantic memory

False
rag_top_k int

Number of similar messages to retrieve for RAG

5
rag_min_similarity float

Minimum similarity threshold for RAG retrieval

0.7
Source code in src/harombe/agent/loop.py
def __init__(
    self,
    llm: LLMClient,
    tools: list[Tool],
    max_steps: int = 10,
    system_prompt: str = "You are a helpful AI assistant.",
    confirm_dangerous: bool = True,
    confirm_callback: Callable[[str, str, dict[str, Any]], bool] | None = None,
    cluster_manager: Optional["ClusterManager"] = None,
    memory_manager: Optional["MemoryManager"] = None,
    mcp_manager: Optional["MCPManager"] = None,
    session_id: str | None = None,
    enable_rag: bool = False,
    rag_top_k: int = 5,
    rag_min_similarity: float = 0.7,
):
    """Initialize the agent.

    Args:
        llm: LLM client for generating responses
        tools: List of available tools
        max_steps: Maximum reasoning steps before forcing final answer
        system_prompt: System prompt for the agent
        confirm_dangerous: Whether to require confirmation for dangerous tools
        confirm_callback: Function called for dangerous tool confirmation.
                         Takes (tool_name, description, args) -> bool.
                         If None and confirm_dangerous=True, auto-denies dangerous tools.
        cluster_manager: Optional cluster manager for distributed routing
        memory_manager: Optional memory manager for conversation persistence
        mcp_manager: Optional MCP manager for external tool servers
        session_id: Session ID for loading/saving conversation history
        enable_rag: Enable retrieval-augmented generation from semantic memory
        rag_top_k: Number of similar messages to retrieve for RAG
        rag_min_similarity: Minimum similarity threshold for RAG retrieval
    """
    self.llm = llm
    self.tools = {tool.schema.name: tool for tool in tools}
    # Merge MCP tools into the tool dict
    if mcp_manager:
        self.tools.update(mcp_manager.get_all_tools())
    self.max_steps = max_steps
    self.system_prompt = system_prompt
    self.confirm_dangerous = confirm_dangerous
    self.confirm_callback = confirm_callback
    self.cluster_manager = cluster_manager
    self.memory_manager = memory_manager
    self.session_id = session_id
    self.enable_rag = enable_rag
    self.rag_top_k = rag_top_k
    self.rag_min_similarity = rag_min_similarity

    # Build tool schemas for LLM (from merged tool dict, includes MCP tools)
    self.tool_schemas = [t.schema.to_openai_format() for t in self.tools.values()]

run(user_message) async

Run the agent on a user message.

Parameters:

Name Type Description Default
user_message str

User's input message

required

Returns:

Type Description
str

Agent's final response

Source code in src/harombe/agent/loop.py
async def run(self, user_message: str) -> str:
    """Run the agent on a user message.

    Args:
        user_message: User's input message

    Returns:
        Agent's final response
    """
    # Initialize state
    state = AgentState(self.system_prompt)

    # Load history if memory is enabled
    if self.memory_manager and self.session_id:
        history = self.memory_manager.load_history(self.session_id)
        # Replace system message with loaded history (which includes system prompt)
        if history:
            state.messages = history

    # Retrieve relevant context if RAG is enabled
    rag_context = None
    if self.enable_rag and self.memory_manager:
        rag_context = await self._retrieve_rag_context(user_message)

    # Add user message (with RAG context if available)
    if rag_context:
        # Inject RAG context into user message
        enhanced_message = self._format_message_with_context(user_message, rag_context)
        state.add_user_message(enhanced_message)
    else:
        state.add_user_message(user_message)

    # Save original user message to memory (without RAG context)
    if self.memory_manager and self.session_id:
        original_msg = Message(role="user", content=user_message)
        self.memory_manager.save_message(self.session_id, original_msg)

    # Use smart routing if cluster manager is available
    llm_client = await self._get_llm_client(user_message, state.messages)

    for step in range(1, self.max_steps + 1):
        # Get LLM response
        response = await llm_client.complete(
            messages=state.messages,
            tools=self.tool_schemas if step < self.max_steps else None,
        )

        # If no tool calls, this is the final answer
        if not response.tool_calls:
            # Save final assistant message
            if self.memory_manager and self.session_id:
                final_msg = Message(role="assistant", content=response.content)
                self.memory_manager.save_message(self.session_id, final_msg)
            return response.content

        # Add assistant response with tool calls
        state.add_assistant_message(response)

        # Save assistant message with tool calls
        if self.memory_manager and self.session_id:
            self.memory_manager.save_message(self.session_id, state.messages[-1])

        # Execute each tool call
        for tool_call in response.tool_calls:
            result = await self._execute_tool_call(tool_call)
            state.add_tool_result(tool_call.id, tool_call.name, result)

            # Save tool result
            if self.memory_manager and self.session_id:
                self.memory_manager.save_message(self.session_id, state.messages[-1])

    # Max steps reached - force final answer
    final_response = await llm_client.complete(
        messages=state.messages,
        tools=None,  # No tools available - must give final answer
    )

    # Save final assistant message
    if self.memory_manager and self.session_id:
        final_msg = Message(role="assistant", content=final_response.content)
        self.memory_manager.save_message(self.session_id, final_msg)

    return final_response.content

AgentState

Maintains conversation state for the agent.

Source code in src/harombe/agent/loop.py
class AgentState:
    """Maintains conversation state for the agent."""

    def __init__(self, system_prompt: str):
        """Initialize agent state.

        Args:
            system_prompt: System message for the agent
        """
        self.messages: list[Message] = [Message(role="system", content=system_prompt)]

    def add_user_message(self, content: str) -> None:
        """Add a user message to the conversation.

        Args:
            content: User message content
        """
        self.messages.append(Message(role="user", content=content))

    def add_assistant_message(self, response: CompletionResponse) -> None:
        """Add an assistant response to the conversation.

        Args:
            response: LLM completion response
        """
        self.messages.append(
            Message(
                role="assistant",
                content=response.content,
                tool_calls=response.tool_calls,
            )
        )

    def add_tool_result(self, tool_call_id: str, tool_name: str, result: str) -> None:
        """Add a tool execution result to the conversation.

        Args:
            tool_call_id: ID of the tool call
            tool_name: Name of the tool that was executed
            result: Tool execution result
        """
        self.messages.append(
            Message(
                role="tool",
                content=result,
                tool_call_id=tool_call_id,
                name=tool_name,
            )
        )

__init__(system_prompt)

Initialize agent state.

Parameters:

Name Type Description Default
system_prompt str

System message for the agent

required
Source code in src/harombe/agent/loop.py
def __init__(self, system_prompt: str):
    """Initialize agent state.

    Args:
        system_prompt: System message for the agent
    """
    self.messages: list[Message] = [Message(role="system", content=system_prompt)]

add_user_message(content)

Add a user message to the conversation.

Parameters:

Name Type Description Default
content str

User message content

required
Source code in src/harombe/agent/loop.py
def add_user_message(self, content: str) -> None:
    """Add a user message to the conversation.

    Args:
        content: User message content
    """
    self.messages.append(Message(role="user", content=content))

add_assistant_message(response)

Add an assistant response to the conversation.

Parameters:

Name Type Description Default
response CompletionResponse

LLM completion response

required
Source code in src/harombe/agent/loop.py
def add_assistant_message(self, response: CompletionResponse) -> None:
    """Add an assistant response to the conversation.

    Args:
        response: LLM completion response
    """
    self.messages.append(
        Message(
            role="assistant",
            content=response.content,
            tool_calls=response.tool_calls,
        )
    )

add_tool_result(tool_call_id, tool_name, result)

Add a tool execution result to the conversation.

Parameters:

Name Type Description Default
tool_call_id str

ID of the tool call

required
tool_name str

Name of the tool that was executed

required
result str

Tool execution result

required
Source code in src/harombe/agent/loop.py
def add_tool_result(self, tool_call_id: str, tool_name: str, result: str) -> None:
    """Add a tool execution result to the conversation.

    Args:
        tool_call_id: ID of the tool call
        tool_name: Name of the tool that was executed
        result: Tool execution result
    """
    self.messages.append(
        Message(
            role="tool",
            content=result,
            tool_call_id=tool_call_id,
            name=tool_name,
        )
    )

AgentBlueprint dataclass

Blueprint for creating an agent instance.

This stores the configuration needed to create a fresh Agent. Each delegation creates a new Agent from this blueprint.

Source code in src/harombe/agent/registry.py
@dataclass
class AgentBlueprint:
    """Blueprint for creating an agent instance.

    This stores the configuration needed to create a fresh Agent.
    Each delegation creates a new Agent from this blueprint.
    """

    name: str
    description: str
    system_prompt: str
    tools_config: dict[str, bool] = field(
        default_factory=lambda: {
            "shell": True,
            "filesystem": True,
            "web_search": True,
        }
    )
    model: str | None = None
    max_steps: int = 10
    enable_rag: bool = False

AgentRegistry

Registry of named agent blueprints.

Agents are registered by name. The registry stores blueprints, not live instances — each delegation creates a fresh Agent.

Source code in src/harombe/agent/registry.py
class AgentRegistry:
    """Registry of named agent blueprints.

    Agents are registered by name. The registry stores blueprints,
    not live instances — each delegation creates a fresh Agent.
    """

    def __init__(self) -> None:
        self._blueprints: dict[str, AgentBlueprint] = {}

    def register(self, blueprint: AgentBlueprint) -> None:
        """Register an agent blueprint.

        Args:
            blueprint: Agent blueprint to register

        Raises:
            ValueError: If an agent with the same name already exists
        """
        if blueprint.name in self._blueprints:
            raise ValueError(f"Agent '{blueprint.name}' already registered")
        self._blueprints[blueprint.name] = blueprint

    def get(self, name: str) -> AgentBlueprint:
        """Get an agent blueprint by name.

        Args:
            name: Agent name

        Returns:
            AgentBlueprint instance

        Raises:
            KeyError: If agent not found
        """
        if name not in self._blueprints:
            raise KeyError(f"Agent '{name}' not found in registry")
        return self._blueprints[name]

    def has(self, name: str) -> bool:
        """Check if an agent is registered."""
        return name in self._blueprints

    def list_agents(self) -> list[AgentBlueprint]:
        """List all registered agent blueprints."""
        return list(self._blueprints.values())

    @property
    def names(self) -> list[str]:
        """List all registered agent names."""
        return list(self._blueprints.keys())

names property

List all registered agent names.

register(blueprint)

Register an agent blueprint.

Parameters:

Name Type Description Default
blueprint AgentBlueprint

Agent blueprint to register

required

Raises:

Type Description
ValueError

If an agent with the same name already exists

Source code in src/harombe/agent/registry.py
def register(self, blueprint: AgentBlueprint) -> None:
    """Register an agent blueprint.

    Args:
        blueprint: Agent blueprint to register

    Raises:
        ValueError: If an agent with the same name already exists
    """
    if blueprint.name in self._blueprints:
        raise ValueError(f"Agent '{blueprint.name}' already registered")
    self._blueprints[blueprint.name] = blueprint

get(name)

Get an agent blueprint by name.

Parameters:

Name Type Description Default
name str

Agent name

required

Returns:

Type Description
AgentBlueprint

AgentBlueprint instance

Raises:

Type Description
KeyError

If agent not found

Source code in src/harombe/agent/registry.py
def get(self, name: str) -> AgentBlueprint:
    """Get an agent blueprint by name.

    Args:
        name: Agent name

    Returns:
        AgentBlueprint instance

    Raises:
        KeyError: If agent not found
    """
    if name not in self._blueprints:
        raise KeyError(f"Agent '{name}' not found in registry")
    return self._blueprints[name]

has(name)

Check if an agent is registered.

Source code in src/harombe/agent/registry.py
def has(self, name: str) -> bool:
    """Check if an agent is registered."""
    return name in self._blueprints

list_agents()

List all registered agent blueprints.

Source code in src/harombe/agent/registry.py
def list_agents(self) -> list[AgentBlueprint]:
    """List all registered agent blueprints."""
    return list(self._blueprints.values())

build_agent_registry(agent_configs)

Create an AgentRegistry from configuration.

Parameters:

Name Type Description Default
agent_configs list[NamedAgentConfig]

List of named agent configurations

required

Returns:

Type Description
AgentRegistry

Populated AgentRegistry

Source code in src/harombe/agent/builder.py
def build_agent_registry(agent_configs: list[NamedAgentConfig]) -> AgentRegistry:
    """Create an AgentRegistry from configuration.

    Args:
        agent_configs: List of named agent configurations

    Returns:
        Populated AgentRegistry
    """
    registry = AgentRegistry()
    for cfg in agent_configs:
        blueprint = AgentBlueprint(
            name=cfg.name,
            description=cfg.description,
            system_prompt=cfg.system_prompt,
            tools_config={
                "shell": cfg.tools.shell,
                "filesystem": cfg.tools.filesystem,
                "web_search": cfg.tools.web_search,
            },
            model=cfg.model,
            max_steps=cfg.max_steps,
            enable_rag=cfg.enable_rag,
        )
        registry.register(blueprint)
    return registry

create_root_delegation_context(config)

Create the root delegation context from config.

Parameters:

Name Type Description Default
config HarombeConfig

Harombe configuration

required

Returns:

Type Description
DelegationContext

Root DelegationContext with max_depth from config

Source code in src/harombe/agent/builder.py
def create_root_delegation_context(config: HarombeConfig) -> DelegationContext:
    """Create the root delegation context from config.

    Args:
        config: Harombe configuration

    Returns:
        Root DelegationContext with max_depth from config
    """
    return DelegationContext(
        chain=[],
        max_depth=config.delegation.max_depth,
    )

options: show_root_heading: true members_order: source

Memory

Conversation persistence, semantic search, and RAG.

harombe.memory

Conversation memory system for harombe.

Provides SQLite-backed conversation persistence with session management, token-based context windowing, and optional semantic search via vector embeddings. When combined with ChromaDB and sentence-transformers, enables Retrieval-Augmented Generation (RAG) for context-aware agent responses.

Components:

  • :class:MemoryManager - High-level API for session and message management
  • :class:MemoryStorage - SQLite storage backend with WAL mode

MemoryManager

High-level memory management interface with optional semantic search.

Source code in src/harombe/memory/manager.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
class MemoryManager:
    """High-level memory management interface with optional semantic search."""

    def __init__(
        self,
        storage_path: str | Path,
        max_history_tokens: int = 4096,
        embedding_client: "EmbeddingClient | None" = None,
        vector_store: "VectorStore | None" = None,
    ):
        """Initialize memory manager.

        Args:
            storage_path: Path to SQLite database
            max_history_tokens: Maximum tokens to load from history
            embedding_client: Optional embedding client for semantic search
            vector_store: Optional vector store for semantic search
        """
        self.storage = MemoryStorage(storage_path)
        self.max_history_tokens = max_history_tokens
        self.embedding_client = embedding_client
        self.vector_store = vector_store

        # Enable semantic search if both components provided
        self.semantic_search_enabled = embedding_client is not None and vector_store is not None

        # Track pending embedding tasks (for testing)
        self._pending_tasks: list[asyncio.Task] = []

    def create_session(
        self,
        system_prompt: str,
        metadata: SessionMetadata | None = None,
        session_id: str | None = None,
    ) -> str:
        """Create a new conversation session.

        Args:
            system_prompt: System prompt for this session
            metadata: Optional session metadata
            session_id: Optional custom session ID (generates UUID if not provided)

        Returns:
            Session ID
        """
        if session_id is None:
            session_id = str(uuid.uuid4())

        self.storage.create_session(
            session_id=session_id,
            system_prompt=system_prompt,
            metadata=metadata,
        )

        return session_id

    def get_session(self, session_id: str) -> SessionRecord | None:
        """Get session information.

        Args:
            session_id: Session identifier

        Returns:
            Session record or None if not found
        """
        return self.storage.get_session(session_id)

    def save_message(self, session_id: str, message: Message) -> int:
        """Save a message to a session.

        If semantic search is enabled, also embeds and stores in vector store.

        Args:
            session_id: Session identifier
            message: Message to save

        Returns:
            Message ID
        """
        # Convert Message to MessageRecord
        tool_calls_json = None
        if message.tool_calls:
            # Serialize tool calls to JSON
            import json

            tool_calls_json = json.dumps(
                [
                    {
                        "id": tc.id,
                        "name": tc.name,
                        "arguments": tc.arguments,
                    }
                    for tc in message.tool_calls
                ]
            )

        record = MessageRecord(
            session_id=session_id,
            role=message.role,
            content=message.content,
            tool_calls=tool_calls_json,
            tool_call_id=message.tool_call_id,
            name=message.name,
        )

        message_id = self.storage.save_message(record)

        # Auto-embed if semantic search is enabled
        if self.semantic_search_enabled and message.content:
            self._embed_message(message_id, session_id, message)

        return message_id

    def _embed_message(self, message_id: int, session_id: str, message: Message) -> None:
        """Embed a message and store in vector store (internal helper).

        Args:
            message_id: Database message ID
            session_id: Session identifier
            message: Message to embed
        """
        # Skip empty messages or system messages
        if not message.content or message.role == "system":
            return

        # Generate embedding
        try:
            # Run async embedding in sync context
            loop = asyncio.get_event_loop()
            if loop.is_running():
                # Already in async context - schedule async task
                # Store reference to prevent task from being garbage collected
                task = loop.create_task(self._embed_message_async(message_id, session_id, message))
                # Add a done callback to capture any exceptions
                task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
                # Track task for testing
                self._pending_tasks.append(task)
                # Clean up completed tasks
                self._pending_tasks = [t for t in self._pending_tasks if not t.done()]
                return

            embedding = loop.run_until_complete(
                self.embedding_client.embed_single(message.content)  # type: ignore[union-attr]
            )
        except Exception:
            # Silently fail - don't break message saving
            return

        # Store in vector database
        try:
            doc_id = f"msg_{message_id}"
            metadata = {
                "session_id": session_id,
                "message_id": message_id,
                "role": message.role,
            }

            self.vector_store.add(  # type: ignore[union-attr]
                ids=[doc_id],
                embeddings=[embedding],
                documents=[message.content],
                metadata=[metadata],
            )
        except Exception:
            # Silently fail - don't break message saving
            pass

    async def _embed_message_async(
        self, message_id: int, session_id: str, message: Message
    ) -> None:
        """Embed message asynchronously (for use in async contexts).

        Args:
            message_id: Database message ID
            session_id: Session identifier
            message: Message to embed
        """
        if not message.content or message.role == "system":
            return

        try:
            embedding = await self.embedding_client.embed_single(message.content)  # type: ignore[union-attr]
        except Exception:
            return

        try:
            doc_id = f"msg_{message_id}"
            metadata = {
                "session_id": session_id,
                "message_id": message_id,
                "role": message.role,
            }

            self.vector_store.add(  # type: ignore[union-attr]
                ids=[doc_id],
                embeddings=[embedding],
                documents=[message.content],
                metadata=[metadata],
            )
        except Exception:
            pass

    async def wait_for_pending_embeddings(self) -> None:
        """Wait for all pending embedding tasks to complete.

        This is primarily for testing to ensure embeddings are indexed
        before performing searches.
        """
        if self._pending_tasks:
            await asyncio.gather(*self._pending_tasks, return_exceptions=True)
            self._pending_tasks.clear()

    def load_history(
        self,
        session_id: str,
        max_tokens: int | None = None,
    ) -> list[Message]:
        """Load conversation history for a session.

        Loads the most recent messages that fit within token limit.

        Args:
            session_id: Session identifier
            max_tokens: Maximum tokens to load (uses default if None)

        Returns:
            List of messages in chronological order
        """
        if max_tokens is None:
            max_tokens = self.max_history_tokens

        # Load all messages (we'll filter in memory)
        # For very large histories, could optimize with pagination
        all_messages = self.storage.load_messages(session_id)

        # If under limit, return all
        total_tokens = sum(estimate_tokens(msg) for msg in all_messages)
        if total_tokens <= max_tokens:
            return all_messages

        # Otherwise, take most recent messages that fit
        result: list[Message] = []
        current_tokens = 0

        # Iterate in reverse (newest first)
        for message in reversed(all_messages):
            msg_tokens = estimate_tokens(message)

            if current_tokens + msg_tokens > max_tokens:
                break

            result.insert(0, message)  # Insert at beginning to maintain order
            current_tokens += msg_tokens

        return result

    def get_recent_messages(
        self,
        session_id: str,
        count: int = 10,
    ) -> list[Message]:
        """Get the N most recent messages.

        Args:
            session_id: Session identifier
            count: Number of messages to retrieve

        Returns:
            List of recent messages in chronological order
        """
        all_messages = self.storage.load_messages(session_id)
        return all_messages[-count:] if len(all_messages) > count else all_messages

    def list_sessions(
        self,
        limit: int = 10,
        offset: int = 0,
    ) -> list[SessionRecord]:
        """List recent sessions.

        Args:
            limit: Maximum number of sessions to return
            offset: Number of sessions to skip

        Returns:
            List of sessions ordered by most recent activity
        """
        return self.storage.list_sessions(limit=limit, offset=offset)

    def delete_session(self, session_id: str) -> bool:
        """Delete a session and all its messages.

        Args:
            session_id: Session identifier

        Returns:
            True if session was deleted, False if not found
        """
        return self.storage.delete_session(session_id)

    def clear_history(self, session_id: str) -> int:
        """Clear all messages from a session (but keep the session).

        Args:
            session_id: Session identifier

        Returns:
            Number of messages deleted
        """
        return self.storage.clear_messages(session_id)

    def get_message_count(self, session_id: str) -> int:
        """Get the total number of messages in a session.

        Args:
            session_id: Session identifier

        Returns:
            Message count
        """
        return self.storage.get_message_count(session_id)

    def prune_old_sessions(self, days: int = 30) -> int:
        """Delete sessions older than specified days.

        Args:
            days: Delete sessions not updated in this many days

        Returns:
            Number of sessions deleted
        """
        return self.storage.prune_old_sessions(days)

    def session_exists(self, session_id: str) -> bool:
        """Check if a session exists.

        Args:
            session_id: Session identifier

        Returns:
            True if session exists
        """
        return self.storage.get_session(session_id) is not None

    def get_or_create_session(
        self,
        session_id: str,
        system_prompt: str,
        metadata: SessionMetadata | None = None,
    ) -> tuple[str, bool]:
        """Get an existing session or create a new one.

        Args:
            session_id: Session identifier
            system_prompt: System prompt (used if creating new session)
            metadata: Session metadata (used if creating new session)

        Returns:
            Tuple of (session_id, created) where created is True if new session
        """
        if self.session_exists(session_id):
            return session_id, False

        self.create_session(
            system_prompt=system_prompt,
            metadata=metadata,
            session_id=session_id,
        )
        return session_id, True

    # Semantic search methods (require embedding_client and vector_store)

    async def search_similar(
        self,
        query: str,
        top_k: int = 5,
        session_id: str | None = None,
        min_similarity: float | None = None,
    ) -> list[Message]:
        """Search for semantically similar messages.

        Args:
            query: Query text to search for
            top_k: Number of results to return
            session_id: Optional session to limit search to
            min_similarity: Optional minimum similarity threshold (0-1)

        Returns:
            List of similar messages ordered by relevance

        Raises:
            RuntimeError: If semantic search is not enabled
        """
        if not self.semantic_search_enabled:
            msg = "Semantic search not enabled. Provide embedding_client and vector_store."
            raise RuntimeError(msg)

        # Generate query embedding
        query_embedding = await self.embedding_client.embed_single(query)  # type: ignore[union-attr]

        # Search vector store
        where = {"session_id": session_id} if session_id else None
        _ids, documents, metadatas, distances = self.vector_store.search(  # type: ignore[union-attr]
            query_embedding=query_embedding,
            top_k=top_k,
            where=where,
        )

        # Convert to Messages and filter by similarity
        results = []
        for doc, meta, distance in zip(documents, metadatas, distances, strict=False):
            # ChromaDB returns distance (lower = more similar)
            # Convert to similarity score (higher = more similar)
            similarity = 1.0 - distance

            if min_similarity is not None and similarity < min_similarity:
                continue

            # Create Message from metadata
            message = Message(
                role=meta["role"],
                content=doc,
            )
            results.append(message)

        return results

    async def get_relevant_context(
        self,
        query: str,
        max_tokens: int = 2048,
        session_id: str | None = None,
    ) -> list[Message]:
        """Get relevant context for a query, limited by token budget.

        Args:
            query: Query text
            max_tokens: Maximum tokens of context to return
            session_id: Optional session to limit search to

        Returns:
            List of relevant messages within token budget

        Raises:
            RuntimeError: If semantic search is not enabled
        """
        if not self.semantic_search_enabled:
            msg = "Semantic search not enabled. Provide embedding_client and vector_store."
            raise RuntimeError(msg)

        # Search for top candidates (over-fetch to allow token filtering)
        candidates = await self.search_similar(
            query=query,
            top_k=20,
            session_id=session_id,
        )

        # Filter by token budget
        results = []
        current_tokens = 0

        for message in candidates:
            msg_tokens = estimate_tokens(message)
            if current_tokens + msg_tokens > max_tokens:
                break
            results.append(message)
            current_tokens += msg_tokens

        return results

    def backfill_embeddings(
        self,
        session_id: str | None = None,
        batch_size: int = 100,
    ) -> int:
        """Backfill embeddings for existing messages.

        Useful when enabling semantic search on existing conversation history.

        Args:
            session_id: Optional session to limit backfill to (None = all sessions)
            batch_size: Number of messages to process at once

        Returns:
            Number of messages embedded

        Raises:
            RuntimeError: If semantic search is not enabled
        """
        if not self.semantic_search_enabled:
            msg = "Semantic search not enabled. Provide embedding_client and vector_store."
            raise RuntimeError(msg)

        # Get messages without embeddings
        # For now, just process all messages
        # TODO: Track which messages have embeddings to avoid duplicates

        sessions = [session_id] if session_id else [s.id for s in self.storage.list_sessions()]

        total_embedded = 0

        for sid in sessions:
            messages_data = self.storage.load_messages(sid)

            for message in messages_data:
                if message.role == "system" or not message.content:
                    continue

                # Get message ID from storage
                # This is a workaround - ideally we'd track message IDs better
                # For now, create a pseudo-ID
                import hashlib

                message_hash = hashlib.md5(
                    f"{sid}:{message.role}:{message.content}".encode()
                ).hexdigest()[:8]
                message_id = f"backfill_{message_hash}"

                try:
                    # Generate embedding
                    loop = asyncio.get_event_loop()
                    embedding = loop.run_until_complete(
                        self.embedding_client.embed_single(message.content)  # type: ignore[union-attr]
                    )

                    # Store in vector database
                    metadata = {
                        "session_id": sid,
                        "message_id": message_id,
                        "role": message.role,
                    }

                    self.vector_store.add(  # type: ignore[union-attr]
                        ids=[message_id],
                        embeddings=[embedding],
                        documents=[message.content],
                        metadata=[metadata],
                    )

                    total_embedded += 1
                except Exception:
                    # Skip failures
                    continue

        return total_embedded

__init__(storage_path, max_history_tokens=4096, embedding_client=None, vector_store=None)

Initialize memory manager.

Parameters:

Name Type Description Default
storage_path str | Path

Path to SQLite database

required
max_history_tokens int

Maximum tokens to load from history

4096
embedding_client EmbeddingClient | None

Optional embedding client for semantic search

None
vector_store VectorStore | None

Optional vector store for semantic search

None
Source code in src/harombe/memory/manager.py
def __init__(
    self,
    storage_path: str | Path,
    max_history_tokens: int = 4096,
    embedding_client: "EmbeddingClient | None" = None,
    vector_store: "VectorStore | None" = None,
):
    """Initialize memory manager.

    Args:
        storage_path: Path to SQLite database
        max_history_tokens: Maximum tokens to load from history
        embedding_client: Optional embedding client for semantic search
        vector_store: Optional vector store for semantic search
    """
    self.storage = MemoryStorage(storage_path)
    self.max_history_tokens = max_history_tokens
    self.embedding_client = embedding_client
    self.vector_store = vector_store

    # Enable semantic search if both components provided
    self.semantic_search_enabled = embedding_client is not None and vector_store is not None

    # Track pending embedding tasks (for testing)
    self._pending_tasks: list[asyncio.Task] = []

create_session(system_prompt, metadata=None, session_id=None)

Create a new conversation session.

Parameters:

Name Type Description Default
system_prompt str

System prompt for this session

required
metadata SessionMetadata | None

Optional session metadata

None
session_id str | None

Optional custom session ID (generates UUID if not provided)

None

Returns:

Type Description
str

Session ID

Source code in src/harombe/memory/manager.py
def create_session(
    self,
    system_prompt: str,
    metadata: SessionMetadata | None = None,
    session_id: str | None = None,
) -> str:
    """Create a new conversation session.

    Args:
        system_prompt: System prompt for this session
        metadata: Optional session metadata
        session_id: Optional custom session ID (generates UUID if not provided)

    Returns:
        Session ID
    """
    if session_id is None:
        session_id = str(uuid.uuid4())

    self.storage.create_session(
        session_id=session_id,
        system_prompt=system_prompt,
        metadata=metadata,
    )

    return session_id

get_session(session_id)

Get session information.

Parameters:

Name Type Description Default
session_id str

Session identifier

required

Returns:

Type Description
SessionRecord | None

Session record or None if not found

Source code in src/harombe/memory/manager.py
def get_session(self, session_id: str) -> SessionRecord | None:
    """Get session information.

    Args:
        session_id: Session identifier

    Returns:
        Session record or None if not found
    """
    return self.storage.get_session(session_id)

save_message(session_id, message)

Save a message to a session.

If semantic search is enabled, also embeds and stores in vector store.

Parameters:

Name Type Description Default
session_id str

Session identifier

required
message Message

Message to save

required

Returns:

Type Description
int

Message ID

Source code in src/harombe/memory/manager.py
def save_message(self, session_id: str, message: Message) -> int:
    """Save a message to a session.

    If semantic search is enabled, also embeds and stores in vector store.

    Args:
        session_id: Session identifier
        message: Message to save

    Returns:
        Message ID
    """
    # Convert Message to MessageRecord
    tool_calls_json = None
    if message.tool_calls:
        # Serialize tool calls to JSON
        import json

        tool_calls_json = json.dumps(
            [
                {
                    "id": tc.id,
                    "name": tc.name,
                    "arguments": tc.arguments,
                }
                for tc in message.tool_calls
            ]
        )

    record = MessageRecord(
        session_id=session_id,
        role=message.role,
        content=message.content,
        tool_calls=tool_calls_json,
        tool_call_id=message.tool_call_id,
        name=message.name,
    )

    message_id = self.storage.save_message(record)

    # Auto-embed if semantic search is enabled
    if self.semantic_search_enabled and message.content:
        self._embed_message(message_id, session_id, message)

    return message_id

wait_for_pending_embeddings() async

Wait for all pending embedding tasks to complete.

This is primarily for testing to ensure embeddings are indexed before performing searches.

Source code in src/harombe/memory/manager.py
async def wait_for_pending_embeddings(self) -> None:
    """Wait for all pending embedding tasks to complete.

    This is primarily for testing to ensure embeddings are indexed
    before performing searches.
    """
    if self._pending_tasks:
        await asyncio.gather(*self._pending_tasks, return_exceptions=True)
        self._pending_tasks.clear()

load_history(session_id, max_tokens=None)

Load conversation history for a session.

Loads the most recent messages that fit within token limit.

Parameters:

Name Type Description Default
session_id str

Session identifier

required
max_tokens int | None

Maximum tokens to load (uses default if None)

None

Returns:

Type Description
list[Message]

List of messages in chronological order

Source code in src/harombe/memory/manager.py
def load_history(
    self,
    session_id: str,
    max_tokens: int | None = None,
) -> list[Message]:
    """Load conversation history for a session.

    Loads the most recent messages that fit within token limit.

    Args:
        session_id: Session identifier
        max_tokens: Maximum tokens to load (uses default if None)

    Returns:
        List of messages in chronological order
    """
    if max_tokens is None:
        max_tokens = self.max_history_tokens

    # Load all messages (we'll filter in memory)
    # For very large histories, could optimize with pagination
    all_messages = self.storage.load_messages(session_id)

    # If under limit, return all
    total_tokens = sum(estimate_tokens(msg) for msg in all_messages)
    if total_tokens <= max_tokens:
        return all_messages

    # Otherwise, take most recent messages that fit
    result: list[Message] = []
    current_tokens = 0

    # Iterate in reverse (newest first)
    for message in reversed(all_messages):
        msg_tokens = estimate_tokens(message)

        if current_tokens + msg_tokens > max_tokens:
            break

        result.insert(0, message)  # Insert at beginning to maintain order
        current_tokens += msg_tokens

    return result

get_recent_messages(session_id, count=10)

Get the N most recent messages.

Parameters:

Name Type Description Default
session_id str

Session identifier

required
count int

Number of messages to retrieve

10

Returns:

Type Description
list[Message]

List of recent messages in chronological order

Source code in src/harombe/memory/manager.py
def get_recent_messages(
    self,
    session_id: str,
    count: int = 10,
) -> list[Message]:
    """Get the N most recent messages.

    Args:
        session_id: Session identifier
        count: Number of messages to retrieve

    Returns:
        List of recent messages in chronological order
    """
    all_messages = self.storage.load_messages(session_id)
    return all_messages[-count:] if len(all_messages) > count else all_messages

list_sessions(limit=10, offset=0)

List recent sessions.

Parameters:

Name Type Description Default
limit int

Maximum number of sessions to return

10
offset int

Number of sessions to skip

0

Returns:

Type Description
list[SessionRecord]

List of sessions ordered by most recent activity

Source code in src/harombe/memory/manager.py
def list_sessions(
    self,
    limit: int = 10,
    offset: int = 0,
) -> list[SessionRecord]:
    """List recent sessions.

    Args:
        limit: Maximum number of sessions to return
        offset: Number of sessions to skip

    Returns:
        List of sessions ordered by most recent activity
    """
    return self.storage.list_sessions(limit=limit, offset=offset)

delete_session(session_id)

Delete a session and all its messages.

Parameters:

Name Type Description Default
session_id str

Session identifier

required

Returns:

Type Description
bool

True if session was deleted, False if not found

Source code in src/harombe/memory/manager.py
def delete_session(self, session_id: str) -> bool:
    """Delete a session and all its messages.

    Args:
        session_id: Session identifier

    Returns:
        True if session was deleted, False if not found
    """
    return self.storage.delete_session(session_id)

clear_history(session_id)

Clear all messages from a session (but keep the session).

Parameters:

Name Type Description Default
session_id str

Session identifier

required

Returns:

Type Description
int

Number of messages deleted

Source code in src/harombe/memory/manager.py
def clear_history(self, session_id: str) -> int:
    """Clear all messages from a session (but keep the session).

    Args:
        session_id: Session identifier

    Returns:
        Number of messages deleted
    """
    return self.storage.clear_messages(session_id)

get_message_count(session_id)

Get the total number of messages in a session.

Parameters:

Name Type Description Default
session_id str

Session identifier

required

Returns:

Type Description
int

Message count

Source code in src/harombe/memory/manager.py
def get_message_count(self, session_id: str) -> int:
    """Get the total number of messages in a session.

    Args:
        session_id: Session identifier

    Returns:
        Message count
    """
    return self.storage.get_message_count(session_id)

prune_old_sessions(days=30)

Delete sessions older than specified days.

Parameters:

Name Type Description Default
days int

Delete sessions not updated in this many days

30

Returns:

Type Description
int

Number of sessions deleted

Source code in src/harombe/memory/manager.py
def prune_old_sessions(self, days: int = 30) -> int:
    """Delete sessions older than specified days.

    Args:
        days: Delete sessions not updated in this many days

    Returns:
        Number of sessions deleted
    """
    return self.storage.prune_old_sessions(days)

session_exists(session_id)

Check if a session exists.

Parameters:

Name Type Description Default
session_id str

Session identifier

required

Returns:

Type Description
bool

True if session exists

Source code in src/harombe/memory/manager.py
def session_exists(self, session_id: str) -> bool:
    """Check if a session exists.

    Args:
        session_id: Session identifier

    Returns:
        True if session exists
    """
    return self.storage.get_session(session_id) is not None

get_or_create_session(session_id, system_prompt, metadata=None)

Get an existing session or create a new one.

Parameters:

Name Type Description Default
session_id str

Session identifier

required
system_prompt str

System prompt (used if creating new session)

required
metadata SessionMetadata | None

Session metadata (used if creating new session)

None

Returns:

Type Description
tuple[str, bool]

Tuple of (session_id, created) where created is True if new session

Source code in src/harombe/memory/manager.py
def get_or_create_session(
    self,
    session_id: str,
    system_prompt: str,
    metadata: SessionMetadata | None = None,
) -> tuple[str, bool]:
    """Get an existing session or create a new one.

    Args:
        session_id: Session identifier
        system_prompt: System prompt (used if creating new session)
        metadata: Session metadata (used if creating new session)

    Returns:
        Tuple of (session_id, created) where created is True if new session
    """
    if self.session_exists(session_id):
        return session_id, False

    self.create_session(
        system_prompt=system_prompt,
        metadata=metadata,
        session_id=session_id,
    )
    return session_id, True

search_similar(query, top_k=5, session_id=None, min_similarity=None) async

Search for semantically similar messages.

Parameters:

Name Type Description Default
query str

Query text to search for

required
top_k int

Number of results to return

5
session_id str | None

Optional session to limit search to

None
min_similarity float | None

Optional minimum similarity threshold (0-1)

None

Returns:

Type Description
list[Message]

List of similar messages ordered by relevance

Raises:

Type Description
RuntimeError

If semantic search is not enabled

Source code in src/harombe/memory/manager.py
async def search_similar(
    self,
    query: str,
    top_k: int = 5,
    session_id: str | None = None,
    min_similarity: float | None = None,
) -> list[Message]:
    """Search for semantically similar messages.

    Args:
        query: Query text to search for
        top_k: Number of results to return
        session_id: Optional session to limit search to
        min_similarity: Optional minimum similarity threshold (0-1)

    Returns:
        List of similar messages ordered by relevance

    Raises:
        RuntimeError: If semantic search is not enabled
    """
    if not self.semantic_search_enabled:
        msg = "Semantic search not enabled. Provide embedding_client and vector_store."
        raise RuntimeError(msg)

    # Generate query embedding
    query_embedding = await self.embedding_client.embed_single(query)  # type: ignore[union-attr]

    # Search vector store
    where = {"session_id": session_id} if session_id else None
    _ids, documents, metadatas, distances = self.vector_store.search(  # type: ignore[union-attr]
        query_embedding=query_embedding,
        top_k=top_k,
        where=where,
    )

    # Convert to Messages and filter by similarity
    results = []
    for doc, meta, distance in zip(documents, metadatas, distances, strict=False):
        # ChromaDB returns distance (lower = more similar)
        # Convert to similarity score (higher = more similar)
        similarity = 1.0 - distance

        if min_similarity is not None and similarity < min_similarity:
            continue

        # Create Message from metadata
        message = Message(
            role=meta["role"],
            content=doc,
        )
        results.append(message)

    return results

get_relevant_context(query, max_tokens=2048, session_id=None) async

Get relevant context for a query, limited by token budget.

Parameters:

Name Type Description Default
query str

Query text

required
max_tokens int

Maximum tokens of context to return

2048
session_id str | None

Optional session to limit search to

None

Returns:

Type Description
list[Message]

List of relevant messages within token budget

Raises:

Type Description
RuntimeError

If semantic search is not enabled

Source code in src/harombe/memory/manager.py
async def get_relevant_context(
    self,
    query: str,
    max_tokens: int = 2048,
    session_id: str | None = None,
) -> list[Message]:
    """Get relevant context for a query, limited by token budget.

    Args:
        query: Query text
        max_tokens: Maximum tokens of context to return
        session_id: Optional session to limit search to

    Returns:
        List of relevant messages within token budget

    Raises:
        RuntimeError: If semantic search is not enabled
    """
    if not self.semantic_search_enabled:
        msg = "Semantic search not enabled. Provide embedding_client and vector_store."
        raise RuntimeError(msg)

    # Search for top candidates (over-fetch to allow token filtering)
    candidates = await self.search_similar(
        query=query,
        top_k=20,
        session_id=session_id,
    )

    # Filter by token budget
    results = []
    current_tokens = 0

    for message in candidates:
        msg_tokens = estimate_tokens(message)
        if current_tokens + msg_tokens > max_tokens:
            break
        results.append(message)
        current_tokens += msg_tokens

    return results

backfill_embeddings(session_id=None, batch_size=100)

Backfill embeddings for existing messages.

Useful when enabling semantic search on existing conversation history.

Parameters:

Name Type Description Default
session_id str | None

Optional session to limit backfill to (None = all sessions)

None
batch_size int

Number of messages to process at once

100

Returns:

Type Description
int

Number of messages embedded

Raises:

Type Description
RuntimeError

If semantic search is not enabled

Source code in src/harombe/memory/manager.py
def backfill_embeddings(
    self,
    session_id: str | None = None,
    batch_size: int = 100,
) -> int:
    """Backfill embeddings for existing messages.

    Useful when enabling semantic search on existing conversation history.

    Args:
        session_id: Optional session to limit backfill to (None = all sessions)
        batch_size: Number of messages to process at once

    Returns:
        Number of messages embedded

    Raises:
        RuntimeError: If semantic search is not enabled
    """
    if not self.semantic_search_enabled:
        msg = "Semantic search not enabled. Provide embedding_client and vector_store."
        raise RuntimeError(msg)

    # Get messages without embeddings
    # For now, just process all messages
    # TODO: Track which messages have embeddings to avoid duplicates

    sessions = [session_id] if session_id else [s.id for s in self.storage.list_sessions()]

    total_embedded = 0

    for sid in sessions:
        messages_data = self.storage.load_messages(sid)

        for message in messages_data:
            if message.role == "system" or not message.content:
                continue

            # Get message ID from storage
            # This is a workaround - ideally we'd track message IDs better
            # For now, create a pseudo-ID
            import hashlib

            message_hash = hashlib.md5(
                f"{sid}:{message.role}:{message.content}".encode()
            ).hexdigest()[:8]
            message_id = f"backfill_{message_hash}"

            try:
                # Generate embedding
                loop = asyncio.get_event_loop()
                embedding = loop.run_until_complete(
                    self.embedding_client.embed_single(message.content)  # type: ignore[union-attr]
                )

                # Store in vector database
                metadata = {
                    "session_id": sid,
                    "message_id": message_id,
                    "role": message.role,
                }

                self.vector_store.add(  # type: ignore[union-attr]
                    ids=[message_id],
                    embeddings=[embedding],
                    documents=[message.content],
                    metadata=[metadata],
                )

                total_embedded += 1
            except Exception:
                # Skip failures
                continue

    return total_embedded

MemoryStorage

SQLite-based storage for conversation memory.

Source code in src/harombe/memory/storage.py
class MemoryStorage:
    """SQLite-based storage for conversation memory."""

    def __init__(self, db_path: str | Path):
        """Initialize storage with database path.

        Args:
            db_path: Path to SQLite database file
        """
        self.db_path = Path(db_path)
        self.db_path.parent.mkdir(parents=True, exist_ok=True)
        self._initialize_db()

    def _initialize_db(self) -> None:
        """Create tables and indexes if they don't exist."""
        with sqlite3.connect(self.db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS sessions (
                    id TEXT PRIMARY KEY,
                    created_at TIMESTAMP NOT NULL,
                    updated_at TIMESTAMP NOT NULL,
                    system_prompt TEXT NOT NULL,
                    metadata TEXT NOT NULL
                )
            """)

            conn.execute("""
                CREATE TABLE IF NOT EXISTS messages (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    session_id TEXT NOT NULL,
                    role TEXT NOT NULL,
                    content TEXT,
                    tool_calls TEXT,
                    tool_call_id TEXT,
                    name TEXT,
                    created_at TIMESTAMP NOT NULL,
                    FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
                )
            """)

            # Create indexes
            conn.execute("CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id)")
            conn.execute("CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at)")
            conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at)")

            conn.commit()

    def create_session(
        self,
        session_id: str,
        system_prompt: str,
        metadata: SessionMetadata | None = None,
    ) -> SessionRecord:
        """Create a new conversation session.

        Args:
            session_id: Unique session identifier
            system_prompt: System prompt for this session
            metadata: Optional session metadata

        Returns:
            Created session record
        """
        if metadata is None:
            metadata = SessionMetadata()

        now = datetime.utcnow()
        session = SessionRecord(
            id=session_id,
            created_at=now,
            updated_at=now,
            system_prompt=system_prompt,
            metadata=metadata,
        )

        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                """
                INSERT INTO sessions (id, created_at, updated_at, system_prompt, metadata)
                VALUES (?, ?, ?, ?, ?)
            """,
                (
                    session.id,
                    session.created_at.isoformat(),
                    session.updated_at.isoformat(),
                    session.system_prompt,
                    session.metadata.model_dump_json(),
                ),
            )
            conn.commit()

        return session

    def get_session(self, session_id: str) -> SessionRecord | None:
        """Get a session by ID.

        Args:
            session_id: Session identifier

        Returns:
            Session record or None if not found
        """
        with sqlite3.connect(self.db_path) as conn:
            conn.row_factory = sqlite3.Row
            cursor = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,))
            row = cursor.fetchone()

            if not row:
                return None

            return SessionRecord(
                id=row["id"],
                created_at=datetime.fromisoformat(row["created_at"]),
                updated_at=datetime.fromisoformat(row["updated_at"]),
                system_prompt=row["system_prompt"],
                metadata=SessionMetadata.model_validate_json(row["metadata"]),
            )

    def update_session_activity(self, session_id: str) -> None:
        """Update the last activity timestamp for a session.

        Args:
            session_id: Session identifier
        """
        with sqlite3.connect(self.db_path) as conn:
            conn.execute(
                "UPDATE sessions SET updated_at = ? WHERE id = ?",
                (datetime.utcnow().isoformat(), session_id),
            )
            conn.commit()

    def save_message(self, message: MessageRecord) -> int:
        """Save a message to storage.

        Args:
            message: Message record to save

        Returns:
            Message ID assigned by database
        """
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.execute(
                """
                INSERT INTO messages
                (session_id, role, content, tool_calls, tool_call_id, name, created_at)
                VALUES (?, ?, ?, ?, ?, ?, ?)
            """,
                (
                    message.session_id,
                    message.role,
                    message.content,
                    message.tool_calls,
                    message.tool_call_id,
                    message.name,
                    message.created_at.isoformat(),
                ),
            )
            conn.commit()

            # Update session activity
            self.update_session_activity(message.session_id)

            message_id = cursor.lastrowid
            assert message_id is not None
            return message_id

    def load_messages(
        self,
        session_id: str,
        limit: int | None = None,
        offset: int = 0,
    ) -> list[Message]:
        """Load messages for a session.

        Args:
            session_id: Session identifier
            limit: Maximum number of messages to return
            offset: Number of messages to skip

        Returns:
            List of messages in chronological order
        """
        query = """
            SELECT * FROM messages
            WHERE session_id = ?
            ORDER BY created_at ASC
        """

        params: list[Any] = [session_id]

        if limit is not None:
            query += " LIMIT ? OFFSET ?"
            params.extend([limit, offset])

        with sqlite3.connect(self.db_path) as conn:
            conn.row_factory = sqlite3.Row
            cursor = conn.execute(query, params)
            rows = cursor.fetchall()

            messages = []
            for row in rows:
                # Parse tool_calls if present
                tool_calls = None
                if row["tool_calls"]:
                    tool_calls_data = json.loads(row["tool_calls"])
                    # Convert to ToolCall objects if needed by client code
                    tool_calls = tool_calls_data

                messages.append(
                    Message(
                        role=row["role"],
                        content=row["content"],
                        tool_calls=tool_calls,
                        tool_call_id=row["tool_call_id"],
                        name=row["name"],
                    )
                )

            return messages

    def get_message_count(self, session_id: str) -> int:
        """Get the total number of messages in a session.

        Args:
            session_id: Session identifier

        Returns:
            Message count
        """
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.execute(
                "SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,)
            )
            result = cursor.fetchone()
            return int(result[0])

    def list_sessions(self, limit: int = 10, offset: int = 0) -> list[SessionRecord]:
        """List recent sessions.

        Args:
            limit: Maximum number of sessions to return
            offset: Number of sessions to skip

        Returns:
            List of sessions ordered by most recent activity
        """
        with sqlite3.connect(self.db_path) as conn:
            conn.row_factory = sqlite3.Row
            cursor = conn.execute(
                """
                SELECT * FROM sessions
                ORDER BY updated_at DESC
                LIMIT ? OFFSET ?
            """,
                (limit, offset),
            )
            rows = cursor.fetchall()

            sessions = []
            for row in rows:
                sessions.append(
                    SessionRecord(
                        id=row["id"],
                        created_at=datetime.fromisoformat(row["created_at"]),
                        updated_at=datetime.fromisoformat(row["updated_at"]),
                        system_prompt=row["system_prompt"],
                        metadata=SessionMetadata.model_validate_json(row["metadata"]),
                    )
                )

            return sessions

    def delete_session(self, session_id: str) -> bool:
        """Delete a session and all its messages.

        Args:
            session_id: Session identifier

        Returns:
            True if session was deleted, False if not found
        """
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
            conn.commit()
            return cursor.rowcount > 0

    def prune_old_sessions(self, days: int) -> int:
        """Delete sessions older than specified days.

        Args:
            days: Delete sessions not updated in this many days

        Returns:
            Number of sessions deleted
        """
        cutoff = datetime.utcnow().timestamp() - (days * 24 * 60 * 60)
        cutoff_dt = datetime.fromtimestamp(cutoff)

        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.execute(
                "DELETE FROM sessions WHERE updated_at < ?", (cutoff_dt.isoformat(),)
            )
            conn.commit()
            return cursor.rowcount

    def clear_messages(self, session_id: str) -> int:
        """Clear all messages from a session (but keep the session).

        Args:
            session_id: Session identifier

        Returns:
            Number of messages deleted
        """
        with sqlite3.connect(self.db_path) as conn:
            cursor = conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
            conn.commit()
            return cursor.rowcount

__init__(db_path)

Initialize storage with database path.

Parameters:

Name Type Description Default
db_path str | Path

Path to SQLite database file

required
Source code in src/harombe/memory/storage.py
def __init__(self, db_path: str | Path):
    """Initialize storage with database path.

    Args:
        db_path: Path to SQLite database file
    """
    self.db_path = Path(db_path)
    self.db_path.parent.mkdir(parents=True, exist_ok=True)
    self._initialize_db()

create_session(session_id, system_prompt, metadata=None)

Create a new conversation session.

Parameters:

Name Type Description Default
session_id str

Unique session identifier

required
system_prompt str

System prompt for this session

required
metadata SessionMetadata | None

Optional session metadata

None

Returns:

Type Description
SessionRecord

Created session record

Source code in src/harombe/memory/storage.py
def create_session(
    self,
    session_id: str,
    system_prompt: str,
    metadata: SessionMetadata | None = None,
) -> SessionRecord:
    """Create a new conversation session.

    Args:
        session_id: Unique session identifier
        system_prompt: System prompt for this session
        metadata: Optional session metadata

    Returns:
        Created session record
    """
    if metadata is None:
        metadata = SessionMetadata()

    now = datetime.utcnow()
    session = SessionRecord(
        id=session_id,
        created_at=now,
        updated_at=now,
        system_prompt=system_prompt,
        metadata=metadata,
    )

    with sqlite3.connect(self.db_path) as conn:
        conn.execute(
            """
            INSERT INTO sessions (id, created_at, updated_at, system_prompt, metadata)
            VALUES (?, ?, ?, ?, ?)
        """,
            (
                session.id,
                session.created_at.isoformat(),
                session.updated_at.isoformat(),
                session.system_prompt,
                session.metadata.model_dump_json(),
            ),
        )
        conn.commit()

    return session

get_session(session_id)

Get a session by ID.

Parameters:

Name Type Description Default
session_id str

Session identifier

required

Returns:

Type Description
SessionRecord | None

Session record or None if not found

Source code in src/harombe/memory/storage.py
def get_session(self, session_id: str) -> SessionRecord | None:
    """Get a session by ID.

    Args:
        session_id: Session identifier

    Returns:
        Session record or None if not found
    """
    with sqlite3.connect(self.db_path) as conn:
        conn.row_factory = sqlite3.Row
        cursor = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,))
        row = cursor.fetchone()

        if not row:
            return None

        return SessionRecord(
            id=row["id"],
            created_at=datetime.fromisoformat(row["created_at"]),
            updated_at=datetime.fromisoformat(row["updated_at"]),
            system_prompt=row["system_prompt"],
            metadata=SessionMetadata.model_validate_json(row["metadata"]),
        )

update_session_activity(session_id)

Update the last activity timestamp for a session.

Parameters:

Name Type Description Default
session_id str

Session identifier

required
Source code in src/harombe/memory/storage.py
def update_session_activity(self, session_id: str) -> None:
    """Update the last activity timestamp for a session.

    Args:
        session_id: Session identifier
    """
    with sqlite3.connect(self.db_path) as conn:
        conn.execute(
            "UPDATE sessions SET updated_at = ? WHERE id = ?",
            (datetime.utcnow().isoformat(), session_id),
        )
        conn.commit()

save_message(message)

Save a message to storage.

Parameters:

Name Type Description Default
message MessageRecord

Message record to save

required

Returns:

Type Description
int

Message ID assigned by database

Source code in src/harombe/memory/storage.py
def save_message(self, message: MessageRecord) -> int:
    """Save a message to storage.

    Args:
        message: Message record to save

    Returns:
        Message ID assigned by database
    """
    with sqlite3.connect(self.db_path) as conn:
        cursor = conn.execute(
            """
            INSERT INTO messages
            (session_id, role, content, tool_calls, tool_call_id, name, created_at)
            VALUES (?, ?, ?, ?, ?, ?, ?)
        """,
            (
                message.session_id,
                message.role,
                message.content,
                message.tool_calls,
                message.tool_call_id,
                message.name,
                message.created_at.isoformat(),
            ),
        )
        conn.commit()

        # Update session activity
        self.update_session_activity(message.session_id)

        message_id = cursor.lastrowid
        assert message_id is not None
        return message_id

load_messages(session_id, limit=None, offset=0)

Load messages for a session.

Parameters:

Name Type Description Default
session_id str

Session identifier

required
limit int | None

Maximum number of messages to return

None
offset int

Number of messages to skip

0

Returns:

Type Description
list[Message]

List of messages in chronological order

Source code in src/harombe/memory/storage.py
def load_messages(
    self,
    session_id: str,
    limit: int | None = None,
    offset: int = 0,
) -> list[Message]:
    """Load messages for a session.

    Args:
        session_id: Session identifier
        limit: Maximum number of messages to return
        offset: Number of messages to skip

    Returns:
        List of messages in chronological order
    """
    query = """
        SELECT * FROM messages
        WHERE session_id = ?
        ORDER BY created_at ASC
    """

    params: list[Any] = [session_id]

    if limit is not None:
        query += " LIMIT ? OFFSET ?"
        params.extend([limit, offset])

    with sqlite3.connect(self.db_path) as conn:
        conn.row_factory = sqlite3.Row
        cursor = conn.execute(query, params)
        rows = cursor.fetchall()

        messages = []
        for row in rows:
            # Parse tool_calls if present
            tool_calls = None
            if row["tool_calls"]:
                tool_calls_data = json.loads(row["tool_calls"])
                # Convert to ToolCall objects if needed by client code
                tool_calls = tool_calls_data

            messages.append(
                Message(
                    role=row["role"],
                    content=row["content"],
                    tool_calls=tool_calls,
                    tool_call_id=row["tool_call_id"],
                    name=row["name"],
                )
            )

        return messages

get_message_count(session_id)

Get the total number of messages in a session.

Parameters:

Name Type Description Default
session_id str

Session identifier

required

Returns:

Type Description
int

Message count

Source code in src/harombe/memory/storage.py
def get_message_count(self, session_id: str) -> int:
    """Get the total number of messages in a session.

    Args:
        session_id: Session identifier

    Returns:
        Message count
    """
    with sqlite3.connect(self.db_path) as conn:
        cursor = conn.execute(
            "SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,)
        )
        result = cursor.fetchone()
        return int(result[0])

list_sessions(limit=10, offset=0)

List recent sessions.

Parameters:

Name Type Description Default
limit int

Maximum number of sessions to return

10
offset int

Number of sessions to skip

0

Returns:

Type Description
list[SessionRecord]

List of sessions ordered by most recent activity

Source code in src/harombe/memory/storage.py
def list_sessions(self, limit: int = 10, offset: int = 0) -> list[SessionRecord]:
    """List recent sessions.

    Args:
        limit: Maximum number of sessions to return
        offset: Number of sessions to skip

    Returns:
        List of sessions ordered by most recent activity
    """
    with sqlite3.connect(self.db_path) as conn:
        conn.row_factory = sqlite3.Row
        cursor = conn.execute(
            """
            SELECT * FROM sessions
            ORDER BY updated_at DESC
            LIMIT ? OFFSET ?
        """,
            (limit, offset),
        )
        rows = cursor.fetchall()

        sessions = []
        for row in rows:
            sessions.append(
                SessionRecord(
                    id=row["id"],
                    created_at=datetime.fromisoformat(row["created_at"]),
                    updated_at=datetime.fromisoformat(row["updated_at"]),
                    system_prompt=row["system_prompt"],
                    metadata=SessionMetadata.model_validate_json(row["metadata"]),
                )
            )

        return sessions

delete_session(session_id)

Delete a session and all its messages.

Parameters:

Name Type Description Default
session_id str

Session identifier

required

Returns:

Type Description
bool

True if session was deleted, False if not found

Source code in src/harombe/memory/storage.py
def delete_session(self, session_id: str) -> bool:
    """Delete a session and all its messages.

    Args:
        session_id: Session identifier

    Returns:
        True if session was deleted, False if not found
    """
    with sqlite3.connect(self.db_path) as conn:
        cursor = conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
        conn.commit()
        return cursor.rowcount > 0

prune_old_sessions(days)

Delete sessions older than specified days.

Parameters:

Name Type Description Default
days int

Delete sessions not updated in this many days

required

Returns:

Type Description
int

Number of sessions deleted

Source code in src/harombe/memory/storage.py
def prune_old_sessions(self, days: int) -> int:
    """Delete sessions older than specified days.

    Args:
        days: Delete sessions not updated in this many days

    Returns:
        Number of sessions deleted
    """
    cutoff = datetime.utcnow().timestamp() - (days * 24 * 60 * 60)
    cutoff_dt = datetime.fromtimestamp(cutoff)

    with sqlite3.connect(self.db_path) as conn:
        cursor = conn.execute(
            "DELETE FROM sessions WHERE updated_at < ?", (cutoff_dt.isoformat(),)
        )
        conn.commit()
        return cursor.rowcount

clear_messages(session_id)

Clear all messages from a session (but keep the session).

Parameters:

Name Type Description Default
session_id str

Session identifier

required

Returns:

Type Description
int

Number of messages deleted

Source code in src/harombe/memory/storage.py
def clear_messages(self, session_id: str) -> int:
    """Clear all messages from a session (but keep the session).

    Args:
        session_id: Session identifier

    Returns:
        Number of messages deleted
    """
    with sqlite3.connect(self.db_path) as conn:
        cursor = conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
        conn.commit()
        return cursor.rowcount

options: show_root_heading: true members_order: source

Security

Defense-in-depth security layer: MCP Gateway, audit logging, credential management, network isolation, HITL gates.

harombe.security

Defense-in-depth security layer for harombe.

Implements the Capability-Container Pattern where every tool runs in its own isolated container. The agent communicates through an MCP Gateway and never directly touches raw credentials, host filesystems, or unrestricted networks.

Components:

  • MCP Gateway (:class:MCPGateway) - Centralized routing and security enforcement
  • Container Management (:class:DockerManager) - Container lifecycle with resource limits
  • Audit Logging (:class:AuditLogger, :class:AuditDatabase) - Immutable event trail with redaction
  • Credential Vault (:class:HashiCorpVault, :class:SOPSBackend, :class:EnvVarBackend) - Multi-backend secrets
  • Secret Scanning (:class:SecretScanner) - Pattern and entropy-based credential detection
  • Network Isolation (:class:EgressFilter, :class:NetworkIsolationManager) - Default-deny egress
  • HITL Gates (:class:HITLGate, :class:RiskClassifier) - Risk-based approval workflows
  • Browser Container (:class:BrowserContainerManager) - Pre-authenticated browser automation
  • Sandbox (:class:SandboxManager) - gVisor-based code execution sandbox
  • Monitoring (:class:SecurityDashboard, :class:AlertRuleEngine, :class:SIEMIntegrator) - Observability
  • Compliance (:class:ComplianceReportGenerator) - SOC 2, GDPR, PCI DSS report generation

Alert

Bases: BaseModel

A generated alert.

Source code in src/harombe/security/alert_rules.py
class Alert(BaseModel):
    """A generated alert."""

    alert_id: str = Field(default_factory=lambda: f"alert-{int(time.time() * 1000)}")
    rule_name: str
    severity: AlertSeverity
    message: str
    event: dict[str, Any]  # Serialized triggering event
    timestamp: datetime = Field(default_factory=datetime.utcnow)
    channels: list[NotificationChannel] = Field(default_factory=list)
    metadata: dict[str, Any] = Field(default_factory=dict)

AlertCondition

Bases: BaseModel

A single condition that an event must match.

Source code in src/harombe/security/alert_rules.py
class AlertCondition(BaseModel):
    """A single condition that an event must match."""

    field: str  # Event field to check (e.g., "event_type", "status", "actor")
    operator: str = "eq"  # "eq", "ne", "contains", "in", "gt", "lt"
    value: Any = None  # Value to compare against

AlertRule

Bases: BaseModel

An alert rule definition.

Source code in src/harombe/security/alert_rules.py
class AlertRule(BaseModel):
    """An alert rule definition."""

    name: str
    description: str = ""
    severity: AlertSeverity = AlertSeverity.MEDIUM
    conditions: list[AlertCondition] = Field(default_factory=list)
    enabled: bool = True
    channels: list[NotificationChannel] = Field(default_factory=lambda: [NotificationChannel.SLACK])
    cooldown_seconds: int = Field(default=300, ge=0)  # 5 min default dedup window
    # Windowed counting: require N matches in time_window_seconds
    count_threshold: int = Field(default=1, ge=1)  # How many matches to trigger
    time_window_seconds: int = Field(default=3600, ge=1)  # Window for counting

AlertRuleEngine

Evaluates audit events against alert rules and dispatches notifications.

Supports: - Field matching with multiple operators - Windowed counting rules (N events in T seconds) - Alert deduplication with configurable cooldown - Multiple notification channels per rule - Statistics tracking

Usage

engine = AlertRuleEngine() engine.add_notifier(SlackNotifier(webhook_url="...")) engine.add_notifier(EmailNotifier(to_addresses=["security@example.com"]))

Evaluate an event

alerts = await engine.evaluate(audit_event)

Source code in src/harombe/security/alert_rules.py
class AlertRuleEngine:
    """Evaluates audit events against alert rules and dispatches notifications.

    Supports:
    - Field matching with multiple operators
    - Windowed counting rules (N events in T seconds)
    - Alert deduplication with configurable cooldown
    - Multiple notification channels per rule
    - Statistics tracking

    Usage:
        engine = AlertRuleEngine()
        engine.add_notifier(SlackNotifier(webhook_url="..."))
        engine.add_notifier(EmailNotifier(to_addresses=["security@example.com"]))

        # Evaluate an event
        alerts = await engine.evaluate(audit_event)
    """

    def __init__(self, rules: list[AlertRule] | None = None):
        """Initialize alert rule engine.

        Args:
            rules: Alert rules. If None, uses default rules.
        """
        self._rules = rules if rules is not None else get_default_rules()
        self._notifiers: dict[NotificationChannel, Notifier] = {}

        # Dedup tracking: rule_name -> last alert timestamp
        self._last_alert_time: dict[str, float] = {}

        # Window counting: rule_name -> list of event timestamps
        self._event_windows: dict[str, list[float]] = defaultdict(list)

        # Statistics
        self.stats: dict[str, Any] = {
            "events_evaluated": 0,
            "alerts_generated": 0,
            "alerts_deduplicated": 0,
            "notifications_sent": 0,
            "notifications_failed": 0,
            "per_rule": {},
        }

        for rule in self._rules:
            self.stats["per_rule"][rule.name] = {
                "matches": 0,
                "alerts": 0,
                "deduplicated": 0,
            }

    @property
    def rules(self) -> list[AlertRule]:
        """Get configured rules."""
        return list(self._rules)

    def add_rule(self, rule: AlertRule) -> None:
        """Add an alert rule."""
        self._rules.append(rule)
        self.stats["per_rule"][rule.name] = {
            "matches": 0,
            "alerts": 0,
            "deduplicated": 0,
        }

    def remove_rule(self, rule_name: str) -> None:
        """Remove an alert rule by name."""
        self._rules = [r for r in self._rules if r.name != rule_name]

    def add_notifier(self, notifier: Notifier) -> None:
        """Register a notification channel."""
        self._notifiers[notifier.channel] = notifier

    def remove_notifier(self, channel: NotificationChannel) -> None:
        """Remove a notification channel."""
        self._notifiers.pop(channel, None)

    async def evaluate(self, event: AuditEvent) -> list[Alert]:
        """Evaluate an event against all rules.

        Returns list of alerts that were triggered and sent.
        """
        self.stats["events_evaluated"] += 1
        triggered_alerts: list[Alert] = []

        for rule in self._rules:
            if not rule.enabled:
                continue

            if self._matches_rule(event, rule):
                rule_stats = self.stats["per_rule"].get(rule.name, {})
                rule_stats["matches"] = rule_stats.get("matches", 0) + 1

                # Check windowed counting
                if not self._check_window(rule):
                    continue

                # Check dedup cooldown
                if self._is_deduplicated(rule):
                    self.stats["alerts_deduplicated"] += 1
                    rule_stats["deduplicated"] = rule_stats.get("deduplicated", 0) + 1
                    continue

                # Generate alert
                alert = self._create_alert(event, rule)
                triggered_alerts.append(alert)

                # Update dedup tracking
                self._last_alert_time[rule.name] = time.time()

                # Update stats
                self.stats["alerts_generated"] += 1
                rule_stats["alerts"] = rule_stats.get("alerts", 0) + 1

                # Send notifications
                await self._send_notifications(alert)

        return triggered_alerts

    def _matches_rule(self, event: AuditEvent, rule: AlertRule) -> bool:
        """Check if event matches all conditions in a rule."""
        if not rule.conditions:
            return False
        return all(_check_condition(event, cond) for cond in rule.conditions)

    def _check_window(self, rule: AlertRule) -> bool:
        """Check windowed counting threshold.

        Returns True if threshold is met.
        """
        now = time.time()
        window = self._event_windows[rule.name]

        # Add current event
        window.append(now)

        # Prune events outside the window
        cutoff = now - rule.time_window_seconds
        self._event_windows[rule.name] = [t for t in window if t >= cutoff]

        # Check threshold
        return len(self._event_windows[rule.name]) >= rule.count_threshold

    def _is_deduplicated(self, rule: AlertRule) -> bool:
        """Check if alert should be suppressed due to dedup cooldown."""
        if rule.cooldown_seconds <= 0:
            return False

        last_time = self._last_alert_time.get(rule.name)
        if last_time is None:
            return False

        elapsed = time.time() - last_time
        return elapsed < rule.cooldown_seconds

    def _create_alert(self, event: AuditEvent, rule: AlertRule) -> Alert:
        """Create an alert from a matching event and rule."""
        event_dict = event.model_dump(mode="json")

        message = rule.description or rule.name
        if event.error_message:
            message += f" - {event.error_message}"
        message += f" (actor: {event.actor}, action: {event.action})"

        return Alert(
            rule_name=rule.name,
            severity=rule.severity,
            message=message,
            event=event_dict,
            channels=rule.channels,
            metadata={
                "rule_description": rule.description,
                "conditions_matched": len(rule.conditions),
            },
        )

    async def _send_notifications(self, alert: Alert) -> list[NotificationResult]:
        """Send alert to all configured channels for the rule."""
        results = []
        for channel in alert.channels:
            notifier = self._notifiers.get(channel)
            if notifier is None:
                continue

            result = await notifier.send(alert)
            results.append(result)

            if result.success:
                self.stats["notifications_sent"] += 1
            else:
                self.stats["notifications_failed"] += 1

        return results

    def get_stats(self) -> dict[str, Any]:
        """Get alert engine statistics."""
        return dict(self.stats)

    def reset_windows(self) -> None:
        """Reset all event counting windows."""
        self._event_windows.clear()

    def reset_dedup(self) -> None:
        """Reset deduplication state."""
        self._last_alert_time.clear()

rules property

Get configured rules.

__init__(rules=None)

Initialize alert rule engine.

Parameters:

Name Type Description Default
rules list[AlertRule] | None

Alert rules. If None, uses default rules.

None
Source code in src/harombe/security/alert_rules.py
def __init__(self, rules: list[AlertRule] | None = None):
    """Initialize alert rule engine.

    Args:
        rules: Alert rules. If None, uses default rules.
    """
    self._rules = rules if rules is not None else get_default_rules()
    self._notifiers: dict[NotificationChannel, Notifier] = {}

    # Dedup tracking: rule_name -> last alert timestamp
    self._last_alert_time: dict[str, float] = {}

    # Window counting: rule_name -> list of event timestamps
    self._event_windows: dict[str, list[float]] = defaultdict(list)

    # Statistics
    self.stats: dict[str, Any] = {
        "events_evaluated": 0,
        "alerts_generated": 0,
        "alerts_deduplicated": 0,
        "notifications_sent": 0,
        "notifications_failed": 0,
        "per_rule": {},
    }

    for rule in self._rules:
        self.stats["per_rule"][rule.name] = {
            "matches": 0,
            "alerts": 0,
            "deduplicated": 0,
        }

add_rule(rule)

Add an alert rule.

Source code in src/harombe/security/alert_rules.py
def add_rule(self, rule: AlertRule) -> None:
    """Add an alert rule."""
    self._rules.append(rule)
    self.stats["per_rule"][rule.name] = {
        "matches": 0,
        "alerts": 0,
        "deduplicated": 0,
    }

remove_rule(rule_name)

Remove an alert rule by name.

Source code in src/harombe/security/alert_rules.py
def remove_rule(self, rule_name: str) -> None:
    """Remove an alert rule by name."""
    self._rules = [r for r in self._rules if r.name != rule_name]

add_notifier(notifier)

Register a notification channel.

Source code in src/harombe/security/alert_rules.py
def add_notifier(self, notifier: Notifier) -> None:
    """Register a notification channel."""
    self._notifiers[notifier.channel] = notifier

remove_notifier(channel)

Remove a notification channel.

Source code in src/harombe/security/alert_rules.py
def remove_notifier(self, channel: NotificationChannel) -> None:
    """Remove a notification channel."""
    self._notifiers.pop(channel, None)

evaluate(event) async

Evaluate an event against all rules.

Returns list of alerts that were triggered and sent.

Source code in src/harombe/security/alert_rules.py
async def evaluate(self, event: AuditEvent) -> list[Alert]:
    """Evaluate an event against all rules.

    Returns list of alerts that were triggered and sent.
    """
    self.stats["events_evaluated"] += 1
    triggered_alerts: list[Alert] = []

    for rule in self._rules:
        if not rule.enabled:
            continue

        if self._matches_rule(event, rule):
            rule_stats = self.stats["per_rule"].get(rule.name, {})
            rule_stats["matches"] = rule_stats.get("matches", 0) + 1

            # Check windowed counting
            if not self._check_window(rule):
                continue

            # Check dedup cooldown
            if self._is_deduplicated(rule):
                self.stats["alerts_deduplicated"] += 1
                rule_stats["deduplicated"] = rule_stats.get("deduplicated", 0) + 1
                continue

            # Generate alert
            alert = self._create_alert(event, rule)
            triggered_alerts.append(alert)

            # Update dedup tracking
            self._last_alert_time[rule.name] = time.time()

            # Update stats
            self.stats["alerts_generated"] += 1
            rule_stats["alerts"] = rule_stats.get("alerts", 0) + 1

            # Send notifications
            await self._send_notifications(alert)

    return triggered_alerts

get_stats()

Get alert engine statistics.

Source code in src/harombe/security/alert_rules.py
def get_stats(self) -> dict[str, Any]:
    """Get alert engine statistics."""
    return dict(self.stats)

reset_windows()

Reset all event counting windows.

Source code in src/harombe/security/alert_rules.py
def reset_windows(self) -> None:
    """Reset all event counting windows."""
    self._event_windows.clear()

reset_dedup()

Reset deduplication state.

Source code in src/harombe/security/alert_rules.py
def reset_dedup(self) -> None:
    """Reset deduplication state."""
    self._last_alert_time.clear()

AlertSeverity

Bases: StrEnum

Alert severity levels.

Source code in src/harombe/security/alert_rules.py
class AlertSeverity(StrEnum):
    """Alert severity levels."""

    INFO = "info"
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    CRITICAL = "critical"

EmailNotifier

Bases: Notifier

Send alerts via email.

Source code in src/harombe/security/alert_rules.py
class EmailNotifier(Notifier):
    """Send alerts via email."""

    channel = NotificationChannel.EMAIL

    def __init__(
        self,
        smtp_host: str = "localhost",
        smtp_port: int = 587,
        from_address: str = "alerts@harombe.local",
        to_addresses: list[str] | None = None,
    ):
        self.smtp_host = smtp_host
        self.smtp_port = smtp_port
        self.from_address = from_address
        self.to_addresses = to_addresses or []

    async def send(self, alert: Alert) -> NotificationResult:
        """Send alert via email.

        In production, this would use aiosmtplib. For now, it formats the
        email payload and returns success (integration point for real SMTP).
        """
        start = time.perf_counter()
        try:
            # Build email payload (actual SMTP sending would go here)
            _payload = {
                "from": self.from_address,
                "to": self.to_addresses,
                "subject": f"[{alert.severity.upper()}] Harombe Alert: {alert.rule_name}",
                "body": alert.message,
            }
            elapsed_ms = (time.perf_counter() - start) * 1000
            return NotificationResult(
                channel=NotificationChannel.EMAIL,
                success=True,
                latency_ms=elapsed_ms,
            )
        except Exception as e:
            elapsed_ms = (time.perf_counter() - start) * 1000
            return NotificationResult(
                channel=NotificationChannel.EMAIL,
                success=False,
                error=str(e),
                latency_ms=elapsed_ms,
            )

send(alert) async

Send alert via email.

In production, this would use aiosmtplib. For now, it formats the email payload and returns success (integration point for real SMTP).

Source code in src/harombe/security/alert_rules.py
async def send(self, alert: Alert) -> NotificationResult:
    """Send alert via email.

    In production, this would use aiosmtplib. For now, it formats the
    email payload and returns success (integration point for real SMTP).
    """
    start = time.perf_counter()
    try:
        # Build email payload (actual SMTP sending would go here)
        _payload = {
            "from": self.from_address,
            "to": self.to_addresses,
            "subject": f"[{alert.severity.upper()}] Harombe Alert: {alert.rule_name}",
            "body": alert.message,
        }
        elapsed_ms = (time.perf_counter() - start) * 1000
        return NotificationResult(
            channel=NotificationChannel.EMAIL,
            success=True,
            latency_ms=elapsed_ms,
        )
    except Exception as e:
        elapsed_ms = (time.perf_counter() - start) * 1000
        return NotificationResult(
            channel=NotificationChannel.EMAIL,
            success=False,
            error=str(e),
            latency_ms=elapsed_ms,
        )

NotificationChannel

Bases: StrEnum

Supported notification channels.

Source code in src/harombe/security/alert_rules.py
class NotificationChannel(StrEnum):
    """Supported notification channels."""

    EMAIL = "email"
    SLACK = "slack"
    PAGERDUTY = "pagerduty"

NotificationResult

Bases: BaseModel

Result of sending a notification.

Source code in src/harombe/security/alert_rules.py
class NotificationResult(BaseModel):
    """Result of sending a notification."""

    channel: NotificationChannel
    success: bool
    error: str | None = None
    latency_ms: float = 0.0

Notifier

Base class for notification channels.

Source code in src/harombe/security/alert_rules.py
class Notifier:
    """Base class for notification channels."""

    channel: NotificationChannel = NotificationChannel.EMAIL

    async def send(self, alert: Alert) -> NotificationResult:
        """Send an alert notification. Override in subclasses."""
        raise NotImplementedError

send(alert) async

Send an alert notification. Override in subclasses.

Source code in src/harombe/security/alert_rules.py
async def send(self, alert: Alert) -> NotificationResult:
    """Send an alert notification. Override in subclasses."""
    raise NotImplementedError

PagerDutyNotifier

Bases: Notifier

Send alerts via PagerDuty Events API.

Source code in src/harombe/security/alert_rules.py
class PagerDutyNotifier(Notifier):
    """Send alerts via PagerDuty Events API."""

    channel = NotificationChannel.PAGERDUTY

    def __init__(
        self,
        routing_key: str = "",
        min_severity: AlertSeverity = AlertSeverity.HIGH,
    ):
        self.routing_key = routing_key
        self.min_severity = min_severity

    def _severity_rank(self, severity: AlertSeverity) -> int:
        """Get numeric rank for severity comparison."""
        ranks = {
            AlertSeverity.INFO: 0,
            AlertSeverity.LOW: 1,
            AlertSeverity.MEDIUM: 2,
            AlertSeverity.HIGH: 3,
            AlertSeverity.CRITICAL: 4,
        }
        return ranks.get(severity, 0)

    async def send(self, alert: Alert) -> NotificationResult:
        """Send alert via PagerDuty.

        Only sends if alert severity meets minimum threshold.
        """
        start = time.perf_counter()

        # Check minimum severity
        if self._severity_rank(alert.severity) < self._severity_rank(self.min_severity):
            elapsed_ms = (time.perf_counter() - start) * 1000
            return NotificationResult(
                channel=NotificationChannel.PAGERDUTY,
                success=True,  # Not an error, just filtered
                latency_ms=elapsed_ms,
            )

        try:
            _severity_map = {
                AlertSeverity.INFO: "info",
                AlertSeverity.LOW: "info",
                AlertSeverity.MEDIUM: "warning",
                AlertSeverity.HIGH: "error",
                AlertSeverity.CRITICAL: "critical",
            }
            _payload = {
                "routing_key": self.routing_key,
                "event_action": "trigger",
                "payload": {
                    "summary": f"{alert.rule_name}: {alert.message}",
                    "severity": _severity_map.get(alert.severity, "info"),
                    "source": "harombe-security",
                },
            }
            elapsed_ms = (time.perf_counter() - start) * 1000
            return NotificationResult(
                channel=NotificationChannel.PAGERDUTY,
                success=True,
                latency_ms=elapsed_ms,
            )
        except Exception as e:
            elapsed_ms = (time.perf_counter() - start) * 1000
            return NotificationResult(
                channel=NotificationChannel.PAGERDUTY,
                success=False,
                error=str(e),
                latency_ms=elapsed_ms,
            )

send(alert) async

Send alert via PagerDuty.

Only sends if alert severity meets minimum threshold.

Source code in src/harombe/security/alert_rules.py
async def send(self, alert: Alert) -> NotificationResult:
    """Send alert via PagerDuty.

    Only sends if alert severity meets minimum threshold.
    """
    start = time.perf_counter()

    # Check minimum severity
    if self._severity_rank(alert.severity) < self._severity_rank(self.min_severity):
        elapsed_ms = (time.perf_counter() - start) * 1000
        return NotificationResult(
            channel=NotificationChannel.PAGERDUTY,
            success=True,  # Not an error, just filtered
            latency_ms=elapsed_ms,
        )

    try:
        _severity_map = {
            AlertSeverity.INFO: "info",
            AlertSeverity.LOW: "info",
            AlertSeverity.MEDIUM: "warning",
            AlertSeverity.HIGH: "error",
            AlertSeverity.CRITICAL: "critical",
        }
        _payload = {
            "routing_key": self.routing_key,
            "event_action": "trigger",
            "payload": {
                "summary": f"{alert.rule_name}: {alert.message}",
                "severity": _severity_map.get(alert.severity, "info"),
                "source": "harombe-security",
            },
        }
        elapsed_ms = (time.perf_counter() - start) * 1000
        return NotificationResult(
            channel=NotificationChannel.PAGERDUTY,
            success=True,
            latency_ms=elapsed_ms,
        )
    except Exception as e:
        elapsed_ms = (time.perf_counter() - start) * 1000
        return NotificationResult(
            channel=NotificationChannel.PAGERDUTY,
            success=False,
            error=str(e),
            latency_ms=elapsed_ms,
        )

SlackNotifier

Bases: Notifier

Send alerts via Slack webhook.

Source code in src/harombe/security/alert_rules.py
class SlackNotifier(Notifier):
    """Send alerts via Slack webhook."""

    channel = NotificationChannel.SLACK

    def __init__(
        self,
        webhook_url: str = "",
        channel_name: str = "#security-alerts",
    ):
        self.webhook_url = webhook_url
        self.channel_name = channel_name

    async def send(self, alert: Alert) -> NotificationResult:
        """Send alert via Slack webhook.

        In production, this would POST to the webhook URL.
        """
        start = time.perf_counter()
        try:
            severity_emoji = {
                AlertSeverity.INFO: ":information_source:",
                AlertSeverity.LOW: ":white_circle:",
                AlertSeverity.MEDIUM: ":large_orange_circle:",
                AlertSeverity.HIGH: ":red_circle:",
                AlertSeverity.CRITICAL: ":rotating_light:",
            }
            _payload = {
                "channel": self.channel_name,
                "text": f"{severity_emoji.get(alert.severity, ':bell:')} *{alert.rule_name}*\n{alert.message}",
                "username": "Harombe Security",
            }
            elapsed_ms = (time.perf_counter() - start) * 1000
            return NotificationResult(
                channel=NotificationChannel.SLACK,
                success=True,
                latency_ms=elapsed_ms,
            )
        except Exception as e:
            elapsed_ms = (time.perf_counter() - start) * 1000
            return NotificationResult(
                channel=NotificationChannel.SLACK,
                success=False,
                error=str(e),
                latency_ms=elapsed_ms,
            )

send(alert) async

Send alert via Slack webhook.

In production, this would POST to the webhook URL.

Source code in src/harombe/security/alert_rules.py
async def send(self, alert: Alert) -> NotificationResult:
    """Send alert via Slack webhook.

    In production, this would POST to the webhook URL.
    """
    start = time.perf_counter()
    try:
        severity_emoji = {
            AlertSeverity.INFO: ":information_source:",
            AlertSeverity.LOW: ":white_circle:",
            AlertSeverity.MEDIUM: ":large_orange_circle:",
            AlertSeverity.HIGH: ":red_circle:",
            AlertSeverity.CRITICAL: ":rotating_light:",
        }
        _payload = {
            "channel": self.channel_name,
            "text": f"{severity_emoji.get(alert.severity, ':bell:')} *{alert.rule_name}*\n{alert.message}",
            "username": "Harombe Security",
        }
        elapsed_ms = (time.perf_counter() - start) * 1000
        return NotificationResult(
            channel=NotificationChannel.SLACK,
            success=True,
            latency_ms=elapsed_ms,
        )
    except Exception as e:
        elapsed_ms = (time.perf_counter() - start) * 1000
        return NotificationResult(
            channel=NotificationChannel.SLACK,
            success=False,
            error=str(e),
            latency_ms=elapsed_ms,
        )

AuditDatabase

SQLite-based audit log database.

Thread-safe database operations for audit logging. Supports async writes, retention policies, and efficient queries.

Source code in src/harombe/security/audit_db.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
class AuditDatabase:
    """SQLite-based audit log database.

    Thread-safe database operations for audit logging.
    Supports async writes, retention policies, and efficient queries.
    """

    SCHEMA_VERSION = 1

    def __init__(
        self,
        db_path: str | Path = "~/.harombe/audit.db",
        retention_days: int = 90,
    ):
        """Initialize audit database.

        Args:
            db_path: Path to SQLite database file
            retention_days: Number of days to retain audit logs
        """
        self.db_path = Path(db_path).expanduser()
        self.db_path.parent.mkdir(parents=True, exist_ok=True)
        self.retention_days = retention_days
        self._initialize_schema()
        self._cleanup_old_records()

    def _get_connection(self) -> sqlite3.Connection:
        """Get database connection with optimized settings."""
        conn = sqlite3.connect(self.db_path, timeout=30.0)
        conn.row_factory = sqlite3.Row
        # Enable WAL mode for better concurrency
        conn.execute("PRAGMA journal_mode=WAL")
        conn.execute("PRAGMA synchronous=NORMAL")
        return conn

    def _initialize_schema(self) -> None:
        """Create database schema if not exists."""
        conn = self._get_connection()
        try:
            # Metadata table
            conn.execute(
                """
                CREATE TABLE IF NOT EXISTS audit_metadata (
                    key TEXT PRIMARY KEY,
                    value TEXT NOT NULL,
                    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
                """
            )

            # Check schema version
            cursor = conn.execute("SELECT value FROM audit_metadata WHERE key = 'schema_version'")
            row = cursor.fetchone()
            if row is None:
                conn.execute(
                    "INSERT INTO audit_metadata (key, value) VALUES ('schema_version', ?)",
                    (str(self.SCHEMA_VERSION),),
                )
                conn.commit()

            # Core audit events table
            conn.execute(
                """
                CREATE TABLE IF NOT EXISTS audit_events (
                    event_id TEXT PRIMARY KEY,
                    correlation_id TEXT NOT NULL,
                    session_id TEXT,
                    timestamp TIMESTAMP NOT NULL,
                    event_type TEXT NOT NULL,
                    actor TEXT NOT NULL,
                    tool_name TEXT,
                    action TEXT NOT NULL,
                    metadata TEXT,
                    duration_ms INTEGER,
                    status TEXT NOT NULL,
                    error_message TEXT
                )
                """
            )

            # Tool calls table
            conn.execute(
                """
                CREATE TABLE IF NOT EXISTS tool_calls (
                    call_id TEXT PRIMARY KEY,
                    correlation_id TEXT NOT NULL,
                    session_id TEXT,
                    timestamp TIMESTAMP NOT NULL,
                    tool_name TEXT NOT NULL,
                    method TEXT NOT NULL,
                    parameters TEXT NOT NULL,
                    result TEXT,
                    error TEXT,
                    duration_ms INTEGER,
                    container_id TEXT
                )
                """
            )

            # Security decisions table
            conn.execute(
                """
                CREATE TABLE IF NOT EXISTS security_decisions (
                    decision_id TEXT PRIMARY KEY,
                    correlation_id TEXT NOT NULL,
                    session_id TEXT,
                    timestamp TIMESTAMP NOT NULL,
                    decision_type TEXT NOT NULL,
                    decision TEXT NOT NULL,
                    reason TEXT NOT NULL,
                    context TEXT,
                    tool_name TEXT,
                    actor TEXT NOT NULL
                )
                """
            )

            # Indexes for efficient queries
            conn.execute(
                """
                CREATE INDEX IF NOT EXISTS idx_events_correlation
                ON audit_events(correlation_id)
                """
            )
            conn.execute(
                """
                CREATE INDEX IF NOT EXISTS idx_events_session
                ON audit_events(session_id)
                """
            )
            conn.execute(
                """
                CREATE INDEX IF NOT EXISTS idx_events_timestamp
                ON audit_events(timestamp)
                """
            )
            conn.execute(
                """
                CREATE INDEX IF NOT EXISTS idx_events_tool
                ON audit_events(tool_name)
                """
            )
            conn.execute(
                """
                CREATE INDEX IF NOT EXISTS idx_tools_correlation
                ON tool_calls(correlation_id)
                """
            )
            conn.execute(
                """
                CREATE INDEX IF NOT EXISTS idx_tools_timestamp
                ON tool_calls(timestamp)
                """
            )
            conn.execute(
                """
                CREATE INDEX IF NOT EXISTS idx_decisions_correlation
                ON security_decisions(correlation_id)
                """
            )
            conn.execute(
                """
                CREATE INDEX IF NOT EXISTS idx_decisions_timestamp
                ON security_decisions(timestamp)
                """
            )

            conn.commit()
        finally:
            conn.close()

    def _cleanup_old_records(self) -> None:
        """Delete records older than retention period."""
        if self.retention_days <= 0:
            return

        cutoff_date = datetime.utcnow() - timedelta(days=self.retention_days)
        conn = self._get_connection()
        try:
            # Clean up old events
            conn.execute("DELETE FROM audit_events WHERE timestamp < ?", (cutoff_date,))
            conn.execute("DELETE FROM tool_calls WHERE timestamp < ?", (cutoff_date,))
            conn.execute("DELETE FROM security_decisions WHERE timestamp < ?", (cutoff_date,))
            conn.commit()

            # Vacuum to reclaim space
            conn.execute("VACUUM")
        finally:
            conn.close()

    def log_event(self, event: AuditEvent) -> None:
        """Log an audit event.

        Args:
            event: Audit event to log
        """
        conn = self._get_connection()
        try:
            conn.execute(
                """
                INSERT INTO audit_events (
                    event_id, correlation_id, session_id, timestamp,
                    event_type, actor, tool_name, action, metadata,
                    duration_ms, status, error_message
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    event.event_id,
                    event.correlation_id,
                    event.session_id,
                    event.timestamp,
                    event.event_type.value,
                    event.actor,
                    event.tool_name,
                    event.action,
                    json.dumps(event.metadata) if event.metadata else None,
                    event.duration_ms,
                    event.status,
                    event.error_message,
                ),
            )
            conn.commit()
        finally:
            conn.close()

    def log_tool_call(self, tool_call: ToolCallRecord) -> None:
        """Log a tool execution.

        Args:
            tool_call: Tool call record to log
        """
        conn = self._get_connection()
        try:
            conn.execute(
                """
                INSERT INTO tool_calls (
                    call_id, correlation_id, session_id, timestamp,
                    tool_name, method, parameters, result, error,
                    duration_ms, container_id
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    tool_call.call_id,
                    tool_call.correlation_id,
                    tool_call.session_id,
                    tool_call.timestamp,
                    tool_call.tool_name,
                    tool_call.method,
                    json.dumps(tool_call.parameters),
                    json.dumps(tool_call.result) if tool_call.result else None,
                    tool_call.error,
                    tool_call.duration_ms,
                    tool_call.container_id,
                ),
            )
            conn.commit()
        finally:
            conn.close()

    def log_security_decision(self, decision: SecurityDecisionRecord) -> None:
        """Log a security decision.

        Args:
            decision: Security decision record to log
        """
        conn = self._get_connection()
        try:
            conn.execute(
                """
                INSERT INTO security_decisions (
                    decision_id, correlation_id, session_id, timestamp,
                    decision_type, decision, reason, context, tool_name, actor
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    decision.decision_id,
                    decision.correlation_id,
                    decision.session_id,
                    decision.timestamp,
                    decision.decision_type,
                    decision.decision.value,
                    decision.reason,
                    json.dumps(decision.context) if decision.context else None,
                    decision.tool_name,
                    decision.actor,
                ),
            )
            conn.commit()
        finally:
            conn.close()

    def get_events_by_correlation(self, correlation_id: str) -> list[dict[str, Any]]:
        """Get all events for a correlation ID.

        Args:
            correlation_id: Correlation ID to query

        Returns:
            List of event dictionaries
        """
        conn = self._get_connection()
        try:
            cursor = conn.execute(
                """
                SELECT * FROM audit_events
                WHERE correlation_id = ?
                ORDER BY timestamp
                """,
                (correlation_id,),
            )
            return [dict(row) for row in cursor.fetchall()]
        finally:
            conn.close()

    def get_events_by_session(
        self,
        session_id: str | None,
        limit: int = 100,
        offset: int = 0,
    ) -> list[dict[str, Any]]:
        """Get events for a session.

        Args:
            session_id: Session ID to query (None returns all events)
            limit: Maximum number of events to return
            offset: Number of events to skip

        Returns:
            List of event dictionaries
        """
        conn = self._get_connection()
        try:
            if session_id is None:
                cursor = conn.execute(
                    """
                    SELECT * FROM audit_events
                    ORDER BY timestamp DESC
                    LIMIT ? OFFSET ?
                    """,
                    (limit, offset),
                )
            else:
                cursor = conn.execute(
                    """
                    SELECT * FROM audit_events
                    WHERE session_id = ?
                    ORDER BY timestamp DESC
                    LIMIT ? OFFSET ?
                    """,
                    (session_id, limit, offset),
                )
            return [dict(row) for row in cursor.fetchall()]
        finally:
            conn.close()

    def get_tool_calls(
        self,
        tool_name: str | None = None,
        start_time: datetime | None = None,
        end_time: datetime | None = None,
        limit: int = 100,
    ) -> list[dict[str, Any]]:
        """Get tool call records.

        Args:
            tool_name: Filter by tool name (optional)
            start_time: Filter by start time (optional)
            end_time: Filter by end time (optional)
            limit: Maximum number of records to return

        Returns:
            List of tool call dictionaries
        """
        conn = self._get_connection()
        try:
            query = "SELECT * FROM tool_calls WHERE 1=1"
            params: list[Any] = []

            if tool_name:
                query += " AND tool_name = ?"
                params.append(tool_name)

            if start_time:
                query += " AND timestamp >= ?"
                params.append(start_time)

            if end_time:
                query += " AND timestamp <= ?"
                params.append(end_time)

            query += " ORDER BY timestamp DESC LIMIT ?"
            params.append(limit)

            cursor = conn.execute(query, params)
            return [dict(row) for row in cursor.fetchall()]
        finally:
            conn.close()

    def get_security_decisions(
        self,
        decision_type: str | None = None,
        decision: SecurityDecision | None = None,
        limit: int = 100,
    ) -> list[dict[str, Any]]:
        """Get security decision records.

        Args:
            decision_type: Filter by decision type (optional)
            decision: Filter by decision outcome (optional)
            limit: Maximum number of records to return

        Returns:
            List of security decision dictionaries
        """
        conn = self._get_connection()
        try:
            query = "SELECT * FROM security_decisions WHERE 1=1"
            params: list[Any] = []

            if decision_type:
                query += " AND decision_type = ?"
                params.append(decision_type)

            if decision:
                query += " AND decision = ?"
                params.append(decision.value)

            query += " ORDER BY timestamp DESC LIMIT ?"
            params.append(limit)

            cursor = conn.execute(query, params)
            return [dict(row) for row in cursor.fetchall()]
        finally:
            conn.close()

    def get_statistics(
        self,
        start_time: datetime | None = None,
        end_time: datetime | None = None,
    ) -> dict[str, Any]:
        """Get audit log statistics.

        Args:
            start_time: Start of time range (optional)
            end_time: End of time range (optional)

        Returns:
            Dictionary with statistics
        """
        conn = self._get_connection()
        try:
            time_filter = ""
            params: list[Any] = []

            if start_time:
                time_filter += " AND timestamp >= ?"
                params.append(start_time)

            if end_time:
                time_filter += " AND timestamp <= ?"
                params.append(end_time)

            # Event statistics
            cursor = conn.execute(
                f"""
                SELECT
                    COUNT(*) as total_events,
                    COUNT(DISTINCT session_id) as unique_sessions,
                    COUNT(DISTINCT correlation_id) as unique_requests
                FROM audit_events
                WHERE 1=1 {time_filter}
                """,
                params,
            )
            event_stats = dict(cursor.fetchone())

            # Tool call statistics
            cursor = conn.execute(
                f"""
                SELECT
                    tool_name,
                    COUNT(*) as call_count,
                    AVG(duration_ms) as avg_duration_ms
                FROM tool_calls
                WHERE 1=1 {time_filter}
                GROUP BY tool_name
                ORDER BY call_count DESC
                """,
                params,
            )
            tool_stats = [dict(row) for row in cursor.fetchall()]

            # Security decision statistics
            cursor = conn.execute(
                f"""
                SELECT
                    decision,
                    COUNT(*) as count
                FROM security_decisions
                WHERE 1=1 {time_filter}
                GROUP BY decision
                """,
                params,
            )
            decision_stats = [dict(row) for row in cursor.fetchall()]

            return {
                "events": event_stats,
                "tools": tool_stats,
                "security_decisions": decision_stats,
            }
        finally:
            conn.close()

__init__(db_path='~/.harombe/audit.db', retention_days=90)

Initialize audit database.

Parameters:

Name Type Description Default
db_path str | Path

Path to SQLite database file

'~/.harombe/audit.db'
retention_days int

Number of days to retain audit logs

90
Source code in src/harombe/security/audit_db.py
def __init__(
    self,
    db_path: str | Path = "~/.harombe/audit.db",
    retention_days: int = 90,
):
    """Initialize audit database.

    Args:
        db_path: Path to SQLite database file
        retention_days: Number of days to retain audit logs
    """
    self.db_path = Path(db_path).expanduser()
    self.db_path.parent.mkdir(parents=True, exist_ok=True)
    self.retention_days = retention_days
    self._initialize_schema()
    self._cleanup_old_records()

log_event(event)

Log an audit event.

Parameters:

Name Type Description Default
event AuditEvent

Audit event to log

required
Source code in src/harombe/security/audit_db.py
def log_event(self, event: AuditEvent) -> None:
    """Log an audit event.

    Args:
        event: Audit event to log
    """
    conn = self._get_connection()
    try:
        conn.execute(
            """
            INSERT INTO audit_events (
                event_id, correlation_id, session_id, timestamp,
                event_type, actor, tool_name, action, metadata,
                duration_ms, status, error_message
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                event.event_id,
                event.correlation_id,
                event.session_id,
                event.timestamp,
                event.event_type.value,
                event.actor,
                event.tool_name,
                event.action,
                json.dumps(event.metadata) if event.metadata else None,
                event.duration_ms,
                event.status,
                event.error_message,
            ),
        )
        conn.commit()
    finally:
        conn.close()

log_tool_call(tool_call)

Log a tool execution.

Parameters:

Name Type Description Default
tool_call ToolCallRecord

Tool call record to log

required
Source code in src/harombe/security/audit_db.py
def log_tool_call(self, tool_call: ToolCallRecord) -> None:
    """Log a tool execution.

    Args:
        tool_call: Tool call record to log
    """
    conn = self._get_connection()
    try:
        conn.execute(
            """
            INSERT INTO tool_calls (
                call_id, correlation_id, session_id, timestamp,
                tool_name, method, parameters, result, error,
                duration_ms, container_id
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                tool_call.call_id,
                tool_call.correlation_id,
                tool_call.session_id,
                tool_call.timestamp,
                tool_call.tool_name,
                tool_call.method,
                json.dumps(tool_call.parameters),
                json.dumps(tool_call.result) if tool_call.result else None,
                tool_call.error,
                tool_call.duration_ms,
                tool_call.container_id,
            ),
        )
        conn.commit()
    finally:
        conn.close()

log_security_decision(decision)

Log a security decision.

Parameters:

Name Type Description Default
decision SecurityDecisionRecord

Security decision record to log

required
Source code in src/harombe/security/audit_db.py
def log_security_decision(self, decision: SecurityDecisionRecord) -> None:
    """Log a security decision.

    Args:
        decision: Security decision record to log
    """
    conn = self._get_connection()
    try:
        conn.execute(
            """
            INSERT INTO security_decisions (
                decision_id, correlation_id, session_id, timestamp,
                decision_type, decision, reason, context, tool_name, actor
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                decision.decision_id,
                decision.correlation_id,
                decision.session_id,
                decision.timestamp,
                decision.decision_type,
                decision.decision.value,
                decision.reason,
                json.dumps(decision.context) if decision.context else None,
                decision.tool_name,
                decision.actor,
            ),
        )
        conn.commit()
    finally:
        conn.close()

get_events_by_correlation(correlation_id)

Get all events for a correlation ID.

Parameters:

Name Type Description Default
correlation_id str

Correlation ID to query

required

Returns:

Type Description
list[dict[str, Any]]

List of event dictionaries

Source code in src/harombe/security/audit_db.py
def get_events_by_correlation(self, correlation_id: str) -> list[dict[str, Any]]:
    """Get all events for a correlation ID.

    Args:
        correlation_id: Correlation ID to query

    Returns:
        List of event dictionaries
    """
    conn = self._get_connection()
    try:
        cursor = conn.execute(
            """
            SELECT * FROM audit_events
            WHERE correlation_id = ?
            ORDER BY timestamp
            """,
            (correlation_id,),
        )
        return [dict(row) for row in cursor.fetchall()]
    finally:
        conn.close()

get_events_by_session(session_id, limit=100, offset=0)

Get events for a session.

Parameters:

Name Type Description Default
session_id str | None

Session ID to query (None returns all events)

required
limit int

Maximum number of events to return

100
offset int

Number of events to skip

0

Returns:

Type Description
list[dict[str, Any]]

List of event dictionaries

Source code in src/harombe/security/audit_db.py
def get_events_by_session(
    self,
    session_id: str | None,
    limit: int = 100,
    offset: int = 0,
) -> list[dict[str, Any]]:
    """Get events for a session.

    Args:
        session_id: Session ID to query (None returns all events)
        limit: Maximum number of events to return
        offset: Number of events to skip

    Returns:
        List of event dictionaries
    """
    conn = self._get_connection()
    try:
        if session_id is None:
            cursor = conn.execute(
                """
                SELECT * FROM audit_events
                ORDER BY timestamp DESC
                LIMIT ? OFFSET ?
                """,
                (limit, offset),
            )
        else:
            cursor = conn.execute(
                """
                SELECT * FROM audit_events
                WHERE session_id = ?
                ORDER BY timestamp DESC
                LIMIT ? OFFSET ?
                """,
                (session_id, limit, offset),
            )
        return [dict(row) for row in cursor.fetchall()]
    finally:
        conn.close()

get_tool_calls(tool_name=None, start_time=None, end_time=None, limit=100)

Get tool call records.

Parameters:

Name Type Description Default
tool_name str | None

Filter by tool name (optional)

None
start_time datetime | None

Filter by start time (optional)

None
end_time datetime | None

Filter by end time (optional)

None
limit int

Maximum number of records to return

100

Returns:

Type Description
list[dict[str, Any]]

List of tool call dictionaries

Source code in src/harombe/security/audit_db.py
def get_tool_calls(
    self,
    tool_name: str | None = None,
    start_time: datetime | None = None,
    end_time: datetime | None = None,
    limit: int = 100,
) -> list[dict[str, Any]]:
    """Get tool call records.

    Args:
        tool_name: Filter by tool name (optional)
        start_time: Filter by start time (optional)
        end_time: Filter by end time (optional)
        limit: Maximum number of records to return

    Returns:
        List of tool call dictionaries
    """
    conn = self._get_connection()
    try:
        query = "SELECT * FROM tool_calls WHERE 1=1"
        params: list[Any] = []

        if tool_name:
            query += " AND tool_name = ?"
            params.append(tool_name)

        if start_time:
            query += " AND timestamp >= ?"
            params.append(start_time)

        if end_time:
            query += " AND timestamp <= ?"
            params.append(end_time)

        query += " ORDER BY timestamp DESC LIMIT ?"
        params.append(limit)

        cursor = conn.execute(query, params)
        return [dict(row) for row in cursor.fetchall()]
    finally:
        conn.close()

get_security_decisions(decision_type=None, decision=None, limit=100)

Get security decision records.

Parameters:

Name Type Description Default
decision_type str | None

Filter by decision type (optional)

None
decision SecurityDecision | None

Filter by decision outcome (optional)

None
limit int

Maximum number of records to return

100

Returns:

Type Description
list[dict[str, Any]]

List of security decision dictionaries

Source code in src/harombe/security/audit_db.py
def get_security_decisions(
    self,
    decision_type: str | None = None,
    decision: SecurityDecision | None = None,
    limit: int = 100,
) -> list[dict[str, Any]]:
    """Get security decision records.

    Args:
        decision_type: Filter by decision type (optional)
        decision: Filter by decision outcome (optional)
        limit: Maximum number of records to return

    Returns:
        List of security decision dictionaries
    """
    conn = self._get_connection()
    try:
        query = "SELECT * FROM security_decisions WHERE 1=1"
        params: list[Any] = []

        if decision_type:
            query += " AND decision_type = ?"
            params.append(decision_type)

        if decision:
            query += " AND decision = ?"
            params.append(decision.value)

        query += " ORDER BY timestamp DESC LIMIT ?"
        params.append(limit)

        cursor = conn.execute(query, params)
        return [dict(row) for row in cursor.fetchall()]
    finally:
        conn.close()

get_statistics(start_time=None, end_time=None)

Get audit log statistics.

Parameters:

Name Type Description Default
start_time datetime | None

Start of time range (optional)

None
end_time datetime | None

End of time range (optional)

None

Returns:

Type Description
dict[str, Any]

Dictionary with statistics

Source code in src/harombe/security/audit_db.py
def get_statistics(
    self,
    start_time: datetime | None = None,
    end_time: datetime | None = None,
) -> dict[str, Any]:
    """Get audit log statistics.

    Args:
        start_time: Start of time range (optional)
        end_time: End of time range (optional)

    Returns:
        Dictionary with statistics
    """
    conn = self._get_connection()
    try:
        time_filter = ""
        params: list[Any] = []

        if start_time:
            time_filter += " AND timestamp >= ?"
            params.append(start_time)

        if end_time:
            time_filter += " AND timestamp <= ?"
            params.append(end_time)

        # Event statistics
        cursor = conn.execute(
            f"""
            SELECT
                COUNT(*) as total_events,
                COUNT(DISTINCT session_id) as unique_sessions,
                COUNT(DISTINCT correlation_id) as unique_requests
            FROM audit_events
            WHERE 1=1 {time_filter}
            """,
            params,
        )
        event_stats = dict(cursor.fetchone())

        # Tool call statistics
        cursor = conn.execute(
            f"""
            SELECT
                tool_name,
                COUNT(*) as call_count,
                AVG(duration_ms) as avg_duration_ms
            FROM tool_calls
            WHERE 1=1 {time_filter}
            GROUP BY tool_name
            ORDER BY call_count DESC
            """,
            params,
        )
        tool_stats = [dict(row) for row in cursor.fetchall()]

        # Security decision statistics
        cursor = conn.execute(
            f"""
            SELECT
                decision,
                COUNT(*) as count
            FROM security_decisions
            WHERE 1=1 {time_filter}
            GROUP BY decision
            """,
            params,
        )
        decision_stats = [dict(row) for row in cursor.fetchall()]

        return {
            "events": event_stats,
            "tools": tool_stats,
            "security_decisions": decision_stats,
        }
    finally:
        conn.close()

AuditEvent

Bases: BaseModel

Audit event record.

Source code in src/harombe/security/audit_db.py
class AuditEvent(BaseModel):
    """Audit event record."""

    event_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    correlation_id: str  # Links request/response pairs
    session_id: str | None = None
    timestamp: datetime = Field(default_factory=datetime.utcnow)
    event_type: EventType
    actor: str  # Agent/user identifier
    tool_name: str | None = None
    action: str
    metadata: dict[str, Any] = Field(default_factory=dict)
    duration_ms: int | None = None
    status: str  # "success", "error", "pending"
    error_message: str | None = None

EventType

Bases: StrEnum

Audit event types.

Source code in src/harombe/security/audit_db.py
class EventType(StrEnum):
    """Audit event types."""

    REQUEST = "request"
    RESPONSE = "response"
    ERROR = "error"
    SECURITY_DECISION = "security_decision"
    TOOL_CALL = "tool_call"

SecurityDecision

Bases: StrEnum

Security decision outcomes.

Source code in src/harombe/security/audit_db.py
class SecurityDecision(StrEnum):
    """Security decision outcomes."""

    ALLOW = "allow"
    DENY = "deny"
    REQUIRE_CONFIRMATION = "require_confirmation"
    REDACTED = "redacted"

SecurityDecisionRecord

Bases: BaseModel

Security decision record.

Source code in src/harombe/security/audit_db.py
class SecurityDecisionRecord(BaseModel):
    """Security decision record."""

    decision_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    correlation_id: str
    session_id: str | None = None
    timestamp: datetime = Field(default_factory=datetime.utcnow)
    decision_type: str  # "authorization", "egress", "secret_scan", "hitl"
    decision: SecurityDecision
    reason: str
    context: dict[str, Any] = Field(default_factory=dict)
    tool_name: str | None = None
    actor: str

ToolCallRecord

Bases: BaseModel

Tool execution record.

Source code in src/harombe/security/audit_db.py
class ToolCallRecord(BaseModel):
    """Tool execution record."""

    call_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    correlation_id: str
    session_id: str | None = None
    timestamp: datetime = Field(default_factory=datetime.utcnow)
    tool_name: str
    method: str
    parameters: dict[str, Any]
    result: dict[str, Any] | None = None
    error: str | None = None
    duration_ms: int | None = None
    container_id: str | None = None  # Docker container ID

AuditLogger

Async audit logger with sensitive data redaction.

Provides non-blocking audit logging with automatic correlation tracking and sensitive data redaction. Integrates with AuditDatabase for storage.

Usage

logger = AuditLogger(db_path="~/.harombe/audit.db")

Log a request

correlation_id = logger.start_request( actor="agent-123", tool_name="filesystem", action="read_file", metadata={"path": "/etc/passwd"} )

Log the response

logger.end_request( correlation_id=correlation_id, status="success", duration_ms=150 )

Source code in src/harombe/security/audit_logger.py
class AuditLogger:
    """Async audit logger with sensitive data redaction.

    Provides non-blocking audit logging with automatic correlation tracking
    and sensitive data redaction. Integrates with AuditDatabase for storage.

    Usage:
        logger = AuditLogger(db_path="~/.harombe/audit.db")

        # Log a request
        correlation_id = logger.start_request(
            actor="agent-123",
            tool_name="filesystem",
            action="read_file",
            metadata={"path": "/etc/passwd"}
        )

        # Log the response
        logger.end_request(
            correlation_id=correlation_id,
            status="success",
            duration_ms=150
        )
    """

    def __init__(
        self,
        db_path: str = "~/.harombe/audit.db",
        retention_days: int = 90,
        redact_sensitive: bool = True,
    ):
        """Initialize audit logger.

        Args:
            db_path: Path to audit database
            retention_days: Number of days to retain logs
            redact_sensitive: If True, redact sensitive data
        """
        self.db = AuditDatabase(db_path=db_path, retention_days=retention_days)
        self.redact_sensitive = redact_sensitive
        self._write_queue: asyncio.Queue[Any] = asyncio.Queue()
        self._writer_task: asyncio.Task | None = None

    async def start(self) -> None:
        """Start async log writer."""
        if self._writer_task is None or self._writer_task.done():
            self._writer_task = asyncio.create_task(self._write_worker())

    async def stop(self) -> None:
        """Stop async log writer."""
        if self._writer_task and not self._writer_task.done():
            await self._write_queue.join()
            self._writer_task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await self._writer_task

    async def _write_worker(self) -> None:
        """Background worker for async writes."""
        while True:
            try:
                item = await self._write_queue.get()
                if item is None:  # Shutdown signal
                    break

                # Write to database
                record_type, record = item
                if record_type == "event":
                    self.db.log_event(record)
                elif record_type == "tool_call":
                    self.db.log_tool_call(record)
                elif record_type == "decision":
                    self.db.log_security_decision(record)

                self._write_queue.task_done()
            except Exception as e:
                # Log errors but don't crash the worker
                print(f"Audit write error: {e}")

    def _redact_if_needed(self, data: Any) -> Any:
        """Redact sensitive data if enabled.

        Args:
            data: Data to potentially redact

        Returns:
            Redacted data
        """
        if not self.redact_sensitive:
            return data

        if isinstance(data, str):
            return SensitiveDataRedactor.redact(data)
        elif isinstance(data, dict):
            return SensitiveDataRedactor.redact_dict(data)
        return data

    def start_request(
        self,
        actor: str,
        tool_name: str | None = None,
        action: str = "unknown",
        metadata: dict[str, Any] | None = None,
        session_id: str | None = None,
    ) -> str:
        """Log the start of a request.

        Args:
            actor: Agent or user identifier
            tool_name: Name of tool being called
            action: Action being performed
            metadata: Additional context
            session_id: Session identifier

        Returns:
            Correlation ID for this request
        """
        correlation_id = str(uuid.uuid4())

        # Redact metadata
        redacted_metadata = self._redact_if_needed(metadata or {})

        event = AuditEvent(
            correlation_id=correlation_id,
            session_id=session_id,
            event_type=EventType.REQUEST,
            actor=actor,
            tool_name=tool_name,
            action=action,
            metadata=redacted_metadata,
            status="pending",
        )

        # Queue for async write
        self._write_queue.put_nowait(("event", event))

        return correlation_id

    def end_request(
        self,
        correlation_id: str,
        status: str = "success",
        duration_ms: int | None = None,
        error_message: str | None = None,
        metadata: dict[str, Any] | None = None,
    ) -> None:
        """Log the completion of a request.

        Args:
            correlation_id: Correlation ID from start_request
            status: "success", "error", or "timeout"
            duration_ms: Request duration in milliseconds
            error_message: Error message if status is "error"
            metadata: Additional response metadata
        """
        # Redact error message
        redacted_error = self._redact_if_needed(error_message) if error_message else None
        redacted_metadata = self._redact_if_needed(metadata or {})

        event = AuditEvent(
            correlation_id=correlation_id,
            event_type=EventType.RESPONSE,
            actor="system",
            action="response",
            metadata=redacted_metadata,
            duration_ms=duration_ms,
            status=status,
            error_message=redacted_error,
        )

        self._write_queue.put_nowait(("event", event))

    def log_tool_call(
        self,
        correlation_id: str,
        tool_name: str,
        method: str,
        parameters: dict[str, Any],
        result: dict[str, Any] | None = None,
        error: str | None = None,
        duration_ms: int | None = None,
        container_id: str | None = None,
        session_id: str | None = None,
    ) -> None:
        """Log a tool execution.

        Args:
            correlation_id: Request correlation ID
            tool_name: Name of tool
            method: Method/function called
            parameters: Tool parameters
            result: Tool result
            error: Error message if failed
            duration_ms: Execution duration
            container_id: Docker container ID
            session_id: Session identifier
        """
        # Redact sensitive data
        redacted_params = self._redact_if_needed(parameters)
        redacted_result = self._redact_if_needed(result) if result else None
        redacted_error = self._redact_if_needed(error) if error else None

        record = ToolCallRecord(
            correlation_id=correlation_id,
            session_id=session_id,
            tool_name=tool_name,
            method=method,
            parameters=redacted_params,
            result=redacted_result,
            error=redacted_error,
            duration_ms=duration_ms,
            container_id=container_id,
        )

        # Write directly to database (synchronous)
        self.db.log_tool_call(record)

    def log_security_decision(
        self,
        correlation_id: str,
        decision_type: str,
        decision: SecurityDecision,
        reason: str,
        actor: str,
        tool_name: str | None = None,
        context: dict[str, Any] | None = None,
        session_id: str | None = None,
    ) -> None:
        """Log a security decision.

        Args:
            correlation_id: Request correlation ID
            decision_type: Type of decision (authorization, egress, etc.)
            decision: Decision outcome
            reason: Reason for decision
            actor: Agent or user making the request
            tool_name: Tool involved in decision
            context: Additional context
            session_id: Session identifier
        """
        # Redact context
        redacted_context = self._redact_if_needed(context or {})

        record = SecurityDecisionRecord(
            correlation_id=correlation_id,
            session_id=session_id,
            decision_type=decision_type,
            decision=decision,
            reason=reason,
            context=redacted_context,
            tool_name=tool_name,
            actor=actor,
        )

        # Write directly to database (synchronous)
        self.db.log_security_decision(record)

    def log_error(
        self,
        correlation_id: str,
        actor: str,
        error_message: str,
        metadata: dict[str, Any] | None = None,
        session_id: str | None = None,
    ) -> None:
        """Log an error event.

        Args:
            correlation_id: Request correlation ID
            actor: Agent or user identifier
            error_message: Error description
            metadata: Additional context
            session_id: Session identifier
        """
        redacted_error = self._redact_if_needed(error_message)
        redacted_metadata = self._redact_if_needed(metadata or {})

        event = AuditEvent(
            correlation_id=correlation_id,
            session_id=session_id,
            event_type=EventType.ERROR,
            actor=actor,
            action="error",
            metadata=redacted_metadata,
            status="error",
            error_message=redacted_error,
        )

        self._write_queue.put_nowait(("event", event))

    # Synchronous methods for sync contexts
    def start_request_sync(
        self,
        actor: str,
        tool_name: str | None = None,
        action: str = "unknown",
        metadata: dict[str, Any] | None = None,
        session_id: str | None = None,
    ) -> str:
        """Synchronous version of start_request."""
        correlation_id = str(uuid.uuid4())
        redacted_metadata = self._redact_if_needed(metadata or {})

        event = AuditEvent(
            correlation_id=correlation_id,
            session_id=session_id,
            event_type=EventType.REQUEST,
            actor=actor,
            tool_name=tool_name,
            action=action,
            metadata=redacted_metadata,
            status="pending",
        )

        # Write synchronously
        self.db.log_event(event)
        return correlation_id

    def end_request_sync(
        self,
        correlation_id: str,
        status: str = "success",
        duration_ms: int | None = None,
        error_message: str | None = None,
        metadata: dict[str, Any] | None = None,
    ) -> None:
        """Synchronous version of end_request."""
        redacted_error = self._redact_if_needed(error_message) if error_message else None
        redacted_metadata = self._redact_if_needed(metadata or {})

        event = AuditEvent(
            correlation_id=correlation_id,
            event_type=EventType.RESPONSE,
            actor="system",
            action="response",
            metadata=redacted_metadata,
            duration_ms=duration_ms,
            status=status,
            error_message=redacted_error,
        )

        self.db.log_event(event)

__init__(db_path='~/.harombe/audit.db', retention_days=90, redact_sensitive=True)

Initialize audit logger.

Parameters:

Name Type Description Default
db_path str

Path to audit database

'~/.harombe/audit.db'
retention_days int

Number of days to retain logs

90
redact_sensitive bool

If True, redact sensitive data

True
Source code in src/harombe/security/audit_logger.py
def __init__(
    self,
    db_path: str = "~/.harombe/audit.db",
    retention_days: int = 90,
    redact_sensitive: bool = True,
):
    """Initialize audit logger.

    Args:
        db_path: Path to audit database
        retention_days: Number of days to retain logs
        redact_sensitive: If True, redact sensitive data
    """
    self.db = AuditDatabase(db_path=db_path, retention_days=retention_days)
    self.redact_sensitive = redact_sensitive
    self._write_queue: asyncio.Queue[Any] = asyncio.Queue()
    self._writer_task: asyncio.Task | None = None

start() async

Start async log writer.

Source code in src/harombe/security/audit_logger.py
async def start(self) -> None:
    """Start async log writer."""
    if self._writer_task is None or self._writer_task.done():
        self._writer_task = asyncio.create_task(self._write_worker())

stop() async

Stop async log writer.

Source code in src/harombe/security/audit_logger.py
async def stop(self) -> None:
    """Stop async log writer."""
    if self._writer_task and not self._writer_task.done():
        await self._write_queue.join()
        self._writer_task.cancel()
        with contextlib.suppress(asyncio.CancelledError):
            await self._writer_task

start_request(actor, tool_name=None, action='unknown', metadata=None, session_id=None)

Log the start of a request.

Parameters:

Name Type Description Default
actor str

Agent or user identifier

required
tool_name str | None

Name of tool being called

None
action str

Action being performed

'unknown'
metadata dict[str, Any] | None

Additional context

None
session_id str | None

Session identifier

None

Returns:

Type Description
str

Correlation ID for this request

Source code in src/harombe/security/audit_logger.py
def start_request(
    self,
    actor: str,
    tool_name: str | None = None,
    action: str = "unknown",
    metadata: dict[str, Any] | None = None,
    session_id: str | None = None,
) -> str:
    """Log the start of a request.

    Args:
        actor: Agent or user identifier
        tool_name: Name of tool being called
        action: Action being performed
        metadata: Additional context
        session_id: Session identifier

    Returns:
        Correlation ID for this request
    """
    correlation_id = str(uuid.uuid4())

    # Redact metadata
    redacted_metadata = self._redact_if_needed(metadata or {})

    event = AuditEvent(
        correlation_id=correlation_id,
        session_id=session_id,
        event_type=EventType.REQUEST,
        actor=actor,
        tool_name=tool_name,
        action=action,
        metadata=redacted_metadata,
        status="pending",
    )

    # Queue for async write
    self._write_queue.put_nowait(("event", event))

    return correlation_id

end_request(correlation_id, status='success', duration_ms=None, error_message=None, metadata=None)

Log the completion of a request.

Parameters:

Name Type Description Default
correlation_id str

Correlation ID from start_request

required
status str

"success", "error", or "timeout"

'success'
duration_ms int | None

Request duration in milliseconds

None
error_message str | None

Error message if status is "error"

None
metadata dict[str, Any] | None

Additional response metadata

None
Source code in src/harombe/security/audit_logger.py
def end_request(
    self,
    correlation_id: str,
    status: str = "success",
    duration_ms: int | None = None,
    error_message: str | None = None,
    metadata: dict[str, Any] | None = None,
) -> None:
    """Log the completion of a request.

    Args:
        correlation_id: Correlation ID from start_request
        status: "success", "error", or "timeout"
        duration_ms: Request duration in milliseconds
        error_message: Error message if status is "error"
        metadata: Additional response metadata
    """
    # Redact error message
    redacted_error = self._redact_if_needed(error_message) if error_message else None
    redacted_metadata = self._redact_if_needed(metadata or {})

    event = AuditEvent(
        correlation_id=correlation_id,
        event_type=EventType.RESPONSE,
        actor="system",
        action="response",
        metadata=redacted_metadata,
        duration_ms=duration_ms,
        status=status,
        error_message=redacted_error,
    )

    self._write_queue.put_nowait(("event", event))

log_tool_call(correlation_id, tool_name, method, parameters, result=None, error=None, duration_ms=None, container_id=None, session_id=None)

Log a tool execution.

Parameters:

Name Type Description Default
correlation_id str

Request correlation ID

required
tool_name str

Name of tool

required
method str

Method/function called

required
parameters dict[str, Any]

Tool parameters

required
result dict[str, Any] | None

Tool result

None
error str | None

Error message if failed

None
duration_ms int | None

Execution duration

None
container_id str | None

Docker container ID

None
session_id str | None

Session identifier

None
Source code in src/harombe/security/audit_logger.py
def log_tool_call(
    self,
    correlation_id: str,
    tool_name: str,
    method: str,
    parameters: dict[str, Any],
    result: dict[str, Any] | None = None,
    error: str | None = None,
    duration_ms: int | None = None,
    container_id: str | None = None,
    session_id: str | None = None,
) -> None:
    """Log a tool execution.

    Args:
        correlation_id: Request correlation ID
        tool_name: Name of tool
        method: Method/function called
        parameters: Tool parameters
        result: Tool result
        error: Error message if failed
        duration_ms: Execution duration
        container_id: Docker container ID
        session_id: Session identifier
    """
    # Redact sensitive data
    redacted_params = self._redact_if_needed(parameters)
    redacted_result = self._redact_if_needed(result) if result else None
    redacted_error = self._redact_if_needed(error) if error else None

    record = ToolCallRecord(
        correlation_id=correlation_id,
        session_id=session_id,
        tool_name=tool_name,
        method=method,
        parameters=redacted_params,
        result=redacted_result,
        error=redacted_error,
        duration_ms=duration_ms,
        container_id=container_id,
    )

    # Write directly to database (synchronous)
    self.db.log_tool_call(record)

log_security_decision(correlation_id, decision_type, decision, reason, actor, tool_name=None, context=None, session_id=None)

Log a security decision.

Parameters:

Name Type Description Default
correlation_id str

Request correlation ID

required
decision_type str

Type of decision (authorization, egress, etc.)

required
decision SecurityDecision

Decision outcome

required
reason str

Reason for decision

required
actor str

Agent or user making the request

required
tool_name str | None

Tool involved in decision

None
context dict[str, Any] | None

Additional context

None
session_id str | None

Session identifier

None
Source code in src/harombe/security/audit_logger.py
def log_security_decision(
    self,
    correlation_id: str,
    decision_type: str,
    decision: SecurityDecision,
    reason: str,
    actor: str,
    tool_name: str | None = None,
    context: dict[str, Any] | None = None,
    session_id: str | None = None,
) -> None:
    """Log a security decision.

    Args:
        correlation_id: Request correlation ID
        decision_type: Type of decision (authorization, egress, etc.)
        decision: Decision outcome
        reason: Reason for decision
        actor: Agent or user making the request
        tool_name: Tool involved in decision
        context: Additional context
        session_id: Session identifier
    """
    # Redact context
    redacted_context = self._redact_if_needed(context or {})

    record = SecurityDecisionRecord(
        correlation_id=correlation_id,
        session_id=session_id,
        decision_type=decision_type,
        decision=decision,
        reason=reason,
        context=redacted_context,
        tool_name=tool_name,
        actor=actor,
    )

    # Write directly to database (synchronous)
    self.db.log_security_decision(record)

log_error(correlation_id, actor, error_message, metadata=None, session_id=None)

Log an error event.

Parameters:

Name Type Description Default
correlation_id str

Request correlation ID

required
actor str

Agent or user identifier

required
error_message str

Error description

required
metadata dict[str, Any] | None

Additional context

None
session_id str | None

Session identifier

None
Source code in src/harombe/security/audit_logger.py
def log_error(
    self,
    correlation_id: str,
    actor: str,
    error_message: str,
    metadata: dict[str, Any] | None = None,
    session_id: str | None = None,
) -> None:
    """Log an error event.

    Args:
        correlation_id: Request correlation ID
        actor: Agent or user identifier
        error_message: Error description
        metadata: Additional context
        session_id: Session identifier
    """
    redacted_error = self._redact_if_needed(error_message)
    redacted_metadata = self._redact_if_needed(metadata or {})

    event = AuditEvent(
        correlation_id=correlation_id,
        session_id=session_id,
        event_type=EventType.ERROR,
        actor=actor,
        action="error",
        metadata=redacted_metadata,
        status="error",
        error_message=redacted_error,
    )

    self._write_queue.put_nowait(("event", event))

start_request_sync(actor, tool_name=None, action='unknown', metadata=None, session_id=None)

Synchronous version of start_request.

Source code in src/harombe/security/audit_logger.py
def start_request_sync(
    self,
    actor: str,
    tool_name: str | None = None,
    action: str = "unknown",
    metadata: dict[str, Any] | None = None,
    session_id: str | None = None,
) -> str:
    """Synchronous version of start_request."""
    correlation_id = str(uuid.uuid4())
    redacted_metadata = self._redact_if_needed(metadata or {})

    event = AuditEvent(
        correlation_id=correlation_id,
        session_id=session_id,
        event_type=EventType.REQUEST,
        actor=actor,
        tool_name=tool_name,
        action=action,
        metadata=redacted_metadata,
        status="pending",
    )

    # Write synchronously
    self.db.log_event(event)
    return correlation_id

end_request_sync(correlation_id, status='success', duration_ms=None, error_message=None, metadata=None)

Synchronous version of end_request.

Source code in src/harombe/security/audit_logger.py
def end_request_sync(
    self,
    correlation_id: str,
    status: str = "success",
    duration_ms: int | None = None,
    error_message: str | None = None,
    metadata: dict[str, Any] | None = None,
) -> None:
    """Synchronous version of end_request."""
    redacted_error = self._redact_if_needed(error_message) if error_message else None
    redacted_metadata = self._redact_if_needed(metadata or {})

    event = AuditEvent(
        correlation_id=correlation_id,
        event_type=EventType.RESPONSE,
        actor="system",
        action="response",
        metadata=redacted_metadata,
        duration_ms=duration_ms,
        status=status,
        error_message=redacted_error,
    )

    self.db.log_event(event)

SensitiveDataRedactor

Redact sensitive information from audit logs.

Detects and redacts: - API keys and tokens - Passwords and secrets - Credit card numbers - Email addresses (optionally) - File paths with credentials

Source code in src/harombe/security/audit_logger.py
class SensitiveDataRedactor:
    """Redact sensitive information from audit logs.

    Detects and redacts:
    - API keys and tokens
    - Passwords and secrets
    - Credit card numbers
    - Email addresses (optionally)
    - File paths with credentials
    """

    # Common patterns for sensitive data
    PATTERNS: ClassVar[dict[str, re.Pattern]] = {
        "api_key": re.compile(
            r"(?i)(api[_-]?key|apikey|access[_-]?token|secret[_-]?key|bearer)\s*[:=]\s*['\"]?([a-zA-Z0-9_\-]{20,})",
            re.IGNORECASE,
        ),
        "password": re.compile(
            r"(?i)(password|passwd|pwd)\s*[:=]\s*['\"]?([^'\"\s]+)",
            re.IGNORECASE,
        ),
        "jwt": re.compile(r"eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+"),
        "credit_card": re.compile(r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b"),
        "email": re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"),
        "private_key": re.compile(
            r"-----BEGIN (RSA |EC )?PRIVATE KEY-----[\s\S]+?-----END (RSA |EC )?PRIVATE KEY-----"
        ),
        "env_secret": re.compile(
            r"(?i)(secret|token|key|password)=['\"]?([a-zA-Z0-9_\-\.]+)",
            re.IGNORECASE,
        ),
    }

    REDACTED_PLACEHOLDER = "[REDACTED]"

    @classmethod
    def redact(cls, text: str, preserve_length: bool = False) -> str:
        """Redact sensitive data from text.

        Args:
            text: Text to redact
            preserve_length: If True, preserve original length with asterisks

        Returns:
            Redacted text
        """
        if not text:
            return text

        result = text

        # Apply all patterns
        for pattern_name, pattern in cls.PATTERNS.items():
            if pattern_name in ("api_key", "password", "env_secret"):
                # For key=value patterns, redact only the value
                result = pattern.sub(
                    lambda m: f"{m.group(1)}={cls._redact_value(m.group(2), preserve_length)}",
                    result,
                )
            else:
                # For standalone patterns, redact the entire match
                result = pattern.sub(
                    lambda m: cls._redact_value(m.group(0), preserve_length), result
                )

        return result

    @classmethod
    def _redact_value(cls, value: str, preserve_length: bool = False) -> str:
        """Redact a single value.

        Args:
            value: Value to redact
            preserve_length: If True, use asterisks to preserve length

        Returns:
            Redacted value
        """
        if preserve_length:
            return "*" * len(value)
        return cls.REDACTED_PLACEHOLDER

    @classmethod
    def redact_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
        """Redact sensitive data from dictionary.

        Args:
            data: Dictionary to redact

        Returns:
            Redacted dictionary (new copy)
        """
        # Sensitive key patterns
        sensitive_keys = {
            "password",
            "passwd",
            "pwd",
            "secret",
            "token",
            "key",
            "api_key",
            "apikey",
            "access_token",
            "auth_token",
            "bearer",
            "private_key",
            "secret_key",
            "client_secret",
        }

        result = {}
        for key, value in data.items():
            # Check if key is sensitive
            key_lower = key.lower().replace("-", "_")
            is_sensitive_key = any(sens in key_lower for sens in sensitive_keys)

            if isinstance(value, str):
                if is_sensitive_key and value:
                    # Redact entire value if key is sensitive
                    result[key] = cls.REDACTED_PLACEHOLDER
                else:
                    # Otherwise redact patterns within value
                    result[key] = cls.redact(value)
            elif isinstance(value, dict):
                result[key] = cls.redact_dict(value)
            elif isinstance(value, list):
                result[key] = [
                    cls.redact_dict(item) if isinstance(item, dict) else item for item in value
                ]
            else:
                result[key] = value
        return result

    @classmethod
    def hash_sensitive(cls, value: str) -> str:
        """Create a hash of sensitive value for correlation.

        Useful for tracking the same credential without logging it.

        Args:
            value: Sensitive value to hash

        Returns:
            SHA256 hash (first 16 characters)
        """
        return hashlib.sha256(value.encode()).hexdigest()[:16]

redact(text, preserve_length=False) classmethod

Redact sensitive data from text.

Parameters:

Name Type Description Default
text str

Text to redact

required
preserve_length bool

If True, preserve original length with asterisks

False

Returns:

Type Description
str

Redacted text

Source code in src/harombe/security/audit_logger.py
@classmethod
def redact(cls, text: str, preserve_length: bool = False) -> str:
    """Redact sensitive data from text.

    Args:
        text: Text to redact
        preserve_length: If True, preserve original length with asterisks

    Returns:
        Redacted text
    """
    if not text:
        return text

    result = text

    # Apply all patterns
    for pattern_name, pattern in cls.PATTERNS.items():
        if pattern_name in ("api_key", "password", "env_secret"):
            # For key=value patterns, redact only the value
            result = pattern.sub(
                lambda m: f"{m.group(1)}={cls._redact_value(m.group(2), preserve_length)}",
                result,
            )
        else:
            # For standalone patterns, redact the entire match
            result = pattern.sub(
                lambda m: cls._redact_value(m.group(0), preserve_length), result
            )

    return result

redact_dict(data) classmethod

Redact sensitive data from dictionary.

Parameters:

Name Type Description Default
data dict[str, Any]

Dictionary to redact

required

Returns:

Type Description
dict[str, Any]

Redacted dictionary (new copy)

Source code in src/harombe/security/audit_logger.py
@classmethod
def redact_dict(cls, data: dict[str, Any]) -> dict[str, Any]:
    """Redact sensitive data from dictionary.

    Args:
        data: Dictionary to redact

    Returns:
        Redacted dictionary (new copy)
    """
    # Sensitive key patterns
    sensitive_keys = {
        "password",
        "passwd",
        "pwd",
        "secret",
        "token",
        "key",
        "api_key",
        "apikey",
        "access_token",
        "auth_token",
        "bearer",
        "private_key",
        "secret_key",
        "client_secret",
    }

    result = {}
    for key, value in data.items():
        # Check if key is sensitive
        key_lower = key.lower().replace("-", "_")
        is_sensitive_key = any(sens in key_lower for sens in sensitive_keys)

        if isinstance(value, str):
            if is_sensitive_key and value:
                # Redact entire value if key is sensitive
                result[key] = cls.REDACTED_PLACEHOLDER
            else:
                # Otherwise redact patterns within value
                result[key] = cls.redact(value)
        elif isinstance(value, dict):
            result[key] = cls.redact_dict(value)
        elif isinstance(value, list):
            result[key] = [
                cls.redact_dict(item) if isinstance(item, dict) else item for item in value
            ]
        else:
            result[key] = value
    return result

hash_sensitive(value) classmethod

Create a hash of sensitive value for correlation.

Useful for tracking the same credential without logging it.

Parameters:

Name Type Description Default
value str

Sensitive value to hash

required

Returns:

Type Description
str

SHA256 hash (first 16 characters)

Source code in src/harombe/security/audit_logger.py
@classmethod
def hash_sensitive(cls, value: str) -> str:
    """Create a hash of sensitive value for correlation.

    Useful for tracking the same credential without logging it.

    Args:
        value: Sensitive value to hash

    Returns:
        SHA256 hash (first 16 characters)
    """
    return hashlib.sha256(value.encode()).hexdigest()[:16]

BrowserContainerManager

Manages browser automation with pre-authentication and session isolation.

Source code in src/harombe/security/browser_manager.py
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
class BrowserContainerManager:
    """Manages browser automation with pre-authentication and session isolation."""

    def __init__(
        self,
        vault_backend: VaultBackend | None = None,
        session_timeout: int = 300,
        max_actions_per_session: int = 100,
        max_concurrent_sessions: int = 5,
        headless: bool = True,
    ):
        """Initialize browser container manager.

        Args:
            vault_backend: Credential vault for pre-authentication
            session_timeout: Session timeout in seconds (default: 5 min)
            max_actions_per_session: Max actions before session refresh
            max_concurrent_sessions: Max concurrent browser sessions
            headless: Run browser in headless mode
        """
        self.vault_backend = vault_backend
        self.session_timeout = session_timeout
        self.max_actions_per_session = max_actions_per_session
        self.max_concurrent_sessions = max_concurrent_sessions
        self.headless = headless

        # Active sessions
        self.sessions: dict[str, BrowserSession] = {}

        # Playwright browser instance
        self._playwright = None
        self._browser: Browser | None = None
        self._lock = asyncio.Lock()

    async def start(self) -> None:
        """Start the browser manager and launch browser."""
        if self._browser:
            logger.warning("Browser already started")
            return

        logger.info("Starting browser manager")
        self._playwright = await async_playwright().start()
        self._browser = await self._playwright.chromium.launch(
            headless=self.headless,
            args=[
                "--no-sandbox",
                "--disable-setuid-sandbox",
                "--disable-dev-shm-usage",
                "--disable-blink-features=AutomationControlled",
            ],
        )
        logger.info("Browser started successfully")

    async def stop(self) -> None:
        """Stop the browser manager and cleanup all sessions."""
        logger.info("Stopping browser manager")

        # Close all active sessions
        session_ids = list(self.sessions.keys())
        for session_id in session_ids:
            await self.close_session(session_id)

        # Close browser
        if self._browser:
            await self._browser.close()
            self._browser = None

        # Stop playwright
        if self._playwright:
            await self._playwright.stop()
            self._playwright = None

        logger.info("Browser manager stopped")

    async def create_session(
        self,
        domain: str,
        session_id: str | None = None,
        auto_inject_credentials: bool = True,
    ) -> str:
        """Create a new browser session with optional pre-authentication.

        Args:
            domain: Domain for credential lookup (e.g., "github.com")
            session_id: Optional session ID (auto-generated if not provided)
            auto_inject_credentials: Automatically inject credentials from vault

        Returns:
            Session ID

        Raises:
            RuntimeError: If browser not started or session limit reached
        """
        if not self._browser:
            raise RuntimeError("Browser not started. Call start() first.")

        async with self._lock:
            # Check session limit
            if len(self.sessions) >= self.max_concurrent_sessions:
                # Cleanup expired sessions
                await self._cleanup_expired_sessions()

                # Still over limit?
                if len(self.sessions) >= self.max_concurrent_sessions:
                    raise RuntimeError(
                        f"Maximum concurrent sessions ({self.max_concurrent_sessions}) reached"
                    )

            # Generate session ID
            if session_id is None:
                session_id = f"sess-{uuid4()}"

            # Create browser context (isolated session)
            context = await self._browser.new_context(
                viewport={"width": 1280, "height": 720},
                user_agent="Mozilla/5.0 (compatible; Harombe/1.0; +https://github.com/smallthinkingmachines/harombe)",
                locale="en-US",
                timezone_id="America/Los_Angeles",
            )

            # Create new page
            page = await context.new_page()

            # Create session
            session = BrowserSession(
                session_id=session_id,
                domain=domain,
                context=context,
                page=page,
            )

            self.sessions[session_id] = session

            logger.info(f"Created browser session {session_id} for domain {domain}")

            # Pre-authenticate if enabled
            if auto_inject_credentials and self.vault_backend:
                await self.inject_credentials(session_id, domain)

            return session_id

    async def inject_credentials(self, session_id: str, domain: str) -> bool:
        """Inject credentials into browser session.

        This is the KEY SECURITY STEP - credentials are injected BEFORE
        the agent gains access to the browser.

        Args:
            session_id: Session ID
            domain: Domain for credential lookup

        Returns:
            True if credentials were injected, False if no credentials found

        Raises:
            ValueError: If session not found
        """
        session = self._get_session(session_id)

        if not self.vault_backend:
            logger.warning("No vault backend configured, skipping credential injection")
            return False

        try:
            # Fetch credentials from vault
            # Vault path: secrets/browser/{domain}
            vault_path = f"browser/{domain}"
            creds_data = await asyncio.to_thread(self.vault_backend.get_secret, vault_path)

            if not creds_data:
                logger.info(f"No credentials found for domain {domain}")
                return False

            # Parse credentials
            credentials = BrowserCredentials(
                domain=domain,
                cookies=creds_data.get("cookies", []),
                local_storage=creds_data.get("localStorage", {}),
                session_storage=creds_data.get("sessionStorage", {}),
                headers=creds_data.get("headers", {}),
            )

            logger.info(f"Injecting credentials for {domain} into session {session_id}")

            # Inject cookies
            if credentials.cookies:
                await session.context.add_cookies(credentials.cookies)
                logger.debug(f"Injected {len(credentials.cookies)} cookies")

            # Inject localStorage and sessionStorage
            # Must navigate to domain first
            if credentials.local_storage or credentials.session_storage:
                # Navigate to domain root to set storage
                await session.page.goto(f"https://{domain}")

                # Inject localStorage
                for key, value in credentials.local_storage.items():
                    await session.page.evaluate(f"localStorage.setItem('{key}', '{value}')")

                # Inject sessionStorage
                for key, value in credentials.session_storage.items():
                    await session.page.evaluate(f"sessionStorage.setItem('{key}', '{value}')")

                logger.debug(
                    f"Injected {len(credentials.local_storage)} localStorage items, "
                    f"{len(credentials.session_storage)} sessionStorage items"
                )

            # Set custom headers (via CDP)
            if credentials.headers:
                # Note: Setting headers requires CDP (Chrome DevTools Protocol)
                # For now, we'll skip this - can be added later if needed
                logger.debug(f"Custom headers: {list(credentials.headers.keys())}")

            logger.info(f"Successfully injected credentials for {domain}")
            return True

        except Exception as e:
            logger.error(f"Failed to inject credentials for {domain}: {e}")
            raise

    async def close_session(self, session_id: str) -> None:
        """Close browser session and cleanup resources.

        Args:
            session_id: Session ID

        Raises:
            ValueError: If session not found
        """
        if session_id not in self.sessions:
            raise ValueError(f"Session {session_id} not found")

        session = self.sessions[session_id]

        try:
            # Close page
            if session.page:
                await session.page.close()

            # Close context (destroys all credentials in memory)
            if session.context:
                await session.context.close()

            logger.info(f"Closed browser session {session_id}")

        except Exception as e:
            logger.error(f"Error closing session {session_id}: {e}")

        finally:
            # Remove from sessions dict
            del self.sessions[session_id]

    def _get_session(self, session_id: str) -> BrowserSession:
        """Get session by ID.

        Args:
            session_id: Session ID

        Returns:
            Browser session

        Raises:
            ValueError: If session not found or expired
        """
        if session_id not in self.sessions:
            raise ValueError(f"Session {session_id} not found")

        session = self.sessions[session_id]

        # Check if expired
        if self._is_session_expired(session):
            raise ValueError(f"Session {session_id} has expired")

        return session

    def _is_session_expired(self, session: BrowserSession) -> bool:
        """Check if session has expired.

        Args:
            session: Browser session

        Returns:
            True if expired, False otherwise
        """
        # Check timeout
        if time.time() - session.last_activity > self.session_timeout:
            return True

        # Check action count
        return session.action_count >= self.max_actions_per_session

    async def _cleanup_expired_sessions(self) -> None:
        """Cleanup expired sessions."""
        expired = [
            sid for sid, session in self.sessions.items() if self._is_session_expired(session)
        ]

        for session_id in expired:
            logger.info(f"Cleaning up expired session {session_id}")
            try:
                await self.close_session(session_id)
            except Exception as e:
                logger.error(f"Error cleaning up session {session_id}: {e}")

    async def navigate(
        self,
        session_id: str,
        url: str,
        wait_for: str = "load",
    ) -> dict[str, Any]:
        """Navigate to URL.

        Args:
            session_id: Session ID
            url: URL to navigate to
            wait_for: Wait condition ("load", "networkidle", "domcontentloaded")

        Returns:
            Navigation result with accessibility snapshot

        Raises:
            ValueError: If session not found or navigation fails
        """
        session = self._get_session(session_id)

        try:
            # Navigate
            await session.page.goto(url, wait_until=wait_for)

            # Update session activity
            session.last_activity = time.time()
            session.action_count += 1

            # Get accessibility snapshot
            snapshot = await self._get_accessibility_snapshot(session.page)

            logger.info(f"Navigated to {url} in session {session_id}")

            return {
                "success": True,
                "url": session.page.url,
                "title": await session.page.title(),
                "snapshot": snapshot,
            }

        except Exception as e:
            logger.error(f"Navigation failed for {url}: {e}")
            raise ValueError(f"Navigation failed: {e!s}") from e

    async def _get_accessibility_snapshot(self, page: Page) -> dict[str, Any]:
        """Generate accessibility snapshot from page.

        Returns semantic accessibility tree instead of raw HTML for security.

        Args:
            page: Playwright page

        Returns:
            Accessibility tree snapshot
        """
        try:
            # Get accessibility snapshot
            snapshot = await page.accessibility.snapshot()

            # Filter sensitive elements (password inputs)
            if snapshot:
                snapshot = self._filter_sensitive_elements(snapshot)

            return snapshot or {}

        except Exception as e:
            logger.error(f"Failed to get accessibility snapshot: {e}")
            return {}

    def _filter_sensitive_elements(self, node: dict[str, Any]) -> dict[str, Any] | None:
        """Filter sensitive elements from accessibility tree.

        Recursively removes password inputs and other sensitive fields.

        Args:
            node: Accessibility tree node

        Returns:
            Filtered node, or None if node should be excluded
        """
        # Exclude password fields
        role = node.get("role", "")
        name = node.get("name", "")

        if role == "textbox" and any(
            keyword in name.lower() for keyword in ["password", "secret", "token", "key"]
        ):
            logger.debug(f"Filtering sensitive field: {name}")
            return None

        # Recursively filter children
        if "children" in node:
            filtered_children = []
            for child in node["children"]:
                filtered_child = self._filter_sensitive_elements(child)
                if filtered_child:
                    filtered_children.append(filtered_child)

            node["children"] = filtered_children

        return node

    async def get_session_info(self, session_id: str) -> dict[str, Any]:
        """Get session information.

        Args:
            session_id: Session ID

        Returns:
            Session information dict
        """
        session = self._get_session(session_id)

        return {
            "session_id": session.session_id,
            "domain": session.domain,
            "created_at": datetime.fromtimestamp(session.created_at, UTC).isoformat(),
            "last_activity": datetime.fromtimestamp(session.last_activity, UTC).isoformat(),
            "action_count": session.action_count,
            "url": session.page.url if session.page else None,
            "title": await session.page.title() if session.page else None,
        }

    async def list_sessions(self) -> list[dict[str, Any]]:
        """List all active sessions.

        Returns:
            List of session info dicts
        """
        return [await self.get_session_info(session_id) for session_id in self.sessions]

__init__(vault_backend=None, session_timeout=300, max_actions_per_session=100, max_concurrent_sessions=5, headless=True)

Initialize browser container manager.

Parameters:

Name Type Description Default
vault_backend VaultBackend | None

Credential vault for pre-authentication

None
session_timeout int

Session timeout in seconds (default: 5 min)

300
max_actions_per_session int

Max actions before session refresh

100
max_concurrent_sessions int

Max concurrent browser sessions

5
headless bool

Run browser in headless mode

True
Source code in src/harombe/security/browser_manager.py
def __init__(
    self,
    vault_backend: VaultBackend | None = None,
    session_timeout: int = 300,
    max_actions_per_session: int = 100,
    max_concurrent_sessions: int = 5,
    headless: bool = True,
):
    """Initialize browser container manager.

    Args:
        vault_backend: Credential vault for pre-authentication
        session_timeout: Session timeout in seconds (default: 5 min)
        max_actions_per_session: Max actions before session refresh
        max_concurrent_sessions: Max concurrent browser sessions
        headless: Run browser in headless mode
    """
    self.vault_backend = vault_backend
    self.session_timeout = session_timeout
    self.max_actions_per_session = max_actions_per_session
    self.max_concurrent_sessions = max_concurrent_sessions
    self.headless = headless

    # Active sessions
    self.sessions: dict[str, BrowserSession] = {}

    # Playwright browser instance
    self._playwright = None
    self._browser: Browser | None = None
    self._lock = asyncio.Lock()

start() async

Start the browser manager and launch browser.

Source code in src/harombe/security/browser_manager.py
async def start(self) -> None:
    """Start the browser manager and launch browser."""
    if self._browser:
        logger.warning("Browser already started")
        return

    logger.info("Starting browser manager")
    self._playwright = await async_playwright().start()
    self._browser = await self._playwright.chromium.launch(
        headless=self.headless,
        args=[
            "--no-sandbox",
            "--disable-setuid-sandbox",
            "--disable-dev-shm-usage",
            "--disable-blink-features=AutomationControlled",
        ],
    )
    logger.info("Browser started successfully")

stop() async

Stop the browser manager and cleanup all sessions.

Source code in src/harombe/security/browser_manager.py
async def stop(self) -> None:
    """Stop the browser manager and cleanup all sessions."""
    logger.info("Stopping browser manager")

    # Close all active sessions
    session_ids = list(self.sessions.keys())
    for session_id in session_ids:
        await self.close_session(session_id)

    # Close browser
    if self._browser:
        await self._browser.close()
        self._browser = None

    # Stop playwright
    if self._playwright:
        await self._playwright.stop()
        self._playwright = None

    logger.info("Browser manager stopped")

create_session(domain, session_id=None, auto_inject_credentials=True) async

Create a new browser session with optional pre-authentication.

Parameters:

Name Type Description Default
domain str

Domain for credential lookup (e.g., "github.com")

required
session_id str | None

Optional session ID (auto-generated if not provided)

None
auto_inject_credentials bool

Automatically inject credentials from vault

True

Returns:

Type Description
str

Session ID

Raises:

Type Description
RuntimeError

If browser not started or session limit reached

Source code in src/harombe/security/browser_manager.py
async def create_session(
    self,
    domain: str,
    session_id: str | None = None,
    auto_inject_credentials: bool = True,
) -> str:
    """Create a new browser session with optional pre-authentication.

    Args:
        domain: Domain for credential lookup (e.g., "github.com")
        session_id: Optional session ID (auto-generated if not provided)
        auto_inject_credentials: Automatically inject credentials from vault

    Returns:
        Session ID

    Raises:
        RuntimeError: If browser not started or session limit reached
    """
    if not self._browser:
        raise RuntimeError("Browser not started. Call start() first.")

    async with self._lock:
        # Check session limit
        if len(self.sessions) >= self.max_concurrent_sessions:
            # Cleanup expired sessions
            await self._cleanup_expired_sessions()

            # Still over limit?
            if len(self.sessions) >= self.max_concurrent_sessions:
                raise RuntimeError(
                    f"Maximum concurrent sessions ({self.max_concurrent_sessions}) reached"
                )

        # Generate session ID
        if session_id is None:
            session_id = f"sess-{uuid4()}"

        # Create browser context (isolated session)
        context = await self._browser.new_context(
            viewport={"width": 1280, "height": 720},
            user_agent="Mozilla/5.0 (compatible; Harombe/1.0; +https://github.com/smallthinkingmachines/harombe)",
            locale="en-US",
            timezone_id="America/Los_Angeles",
        )

        # Create new page
        page = await context.new_page()

        # Create session
        session = BrowserSession(
            session_id=session_id,
            domain=domain,
            context=context,
            page=page,
        )

        self.sessions[session_id] = session

        logger.info(f"Created browser session {session_id} for domain {domain}")

        # Pre-authenticate if enabled
        if auto_inject_credentials and self.vault_backend:
            await self.inject_credentials(session_id, domain)

        return session_id

inject_credentials(session_id, domain) async

Inject credentials into browser session.

This is the KEY SECURITY STEP - credentials are injected BEFORE the agent gains access to the browser.

Parameters:

Name Type Description Default
session_id str

Session ID

required
domain str

Domain for credential lookup

required

Returns:

Type Description
bool

True if credentials were injected, False if no credentials found

Raises:

Type Description
ValueError

If session not found

Source code in src/harombe/security/browser_manager.py
async def inject_credentials(self, session_id: str, domain: str) -> bool:
    """Inject credentials into browser session.

    This is the KEY SECURITY STEP - credentials are injected BEFORE
    the agent gains access to the browser.

    Args:
        session_id: Session ID
        domain: Domain for credential lookup

    Returns:
        True if credentials were injected, False if no credentials found

    Raises:
        ValueError: If session not found
    """
    session = self._get_session(session_id)

    if not self.vault_backend:
        logger.warning("No vault backend configured, skipping credential injection")
        return False

    try:
        # Fetch credentials from vault
        # Vault path: secrets/browser/{domain}
        vault_path = f"browser/{domain}"
        creds_data = await asyncio.to_thread(self.vault_backend.get_secret, vault_path)

        if not creds_data:
            logger.info(f"No credentials found for domain {domain}")
            return False

        # Parse credentials
        credentials = BrowserCredentials(
            domain=domain,
            cookies=creds_data.get("cookies", []),
            local_storage=creds_data.get("localStorage", {}),
            session_storage=creds_data.get("sessionStorage", {}),
            headers=creds_data.get("headers", {}),
        )

        logger.info(f"Injecting credentials for {domain} into session {session_id}")

        # Inject cookies
        if credentials.cookies:
            await session.context.add_cookies(credentials.cookies)
            logger.debug(f"Injected {len(credentials.cookies)} cookies")

        # Inject localStorage and sessionStorage
        # Must navigate to domain first
        if credentials.local_storage or credentials.session_storage:
            # Navigate to domain root to set storage
            await session.page.goto(f"https://{domain}")

            # Inject localStorage
            for key, value in credentials.local_storage.items():
                await session.page.evaluate(f"localStorage.setItem('{key}', '{value}')")

            # Inject sessionStorage
            for key, value in credentials.session_storage.items():
                await session.page.evaluate(f"sessionStorage.setItem('{key}', '{value}')")

            logger.debug(
                f"Injected {len(credentials.local_storage)} localStorage items, "
                f"{len(credentials.session_storage)} sessionStorage items"
            )

        # Set custom headers (via CDP)
        if credentials.headers:
            # Note: Setting headers requires CDP (Chrome DevTools Protocol)
            # For now, we'll skip this - can be added later if needed
            logger.debug(f"Custom headers: {list(credentials.headers.keys())}")

        logger.info(f"Successfully injected credentials for {domain}")
        return True

    except Exception as e:
        logger.error(f"Failed to inject credentials for {domain}: {e}")
        raise

close_session(session_id) async

Close browser session and cleanup resources.

Parameters:

Name Type Description Default
session_id str

Session ID

required

Raises:

Type Description
ValueError

If session not found

Source code in src/harombe/security/browser_manager.py
async def close_session(self, session_id: str) -> None:
    """Close browser session and cleanup resources.

    Args:
        session_id: Session ID

    Raises:
        ValueError: If session not found
    """
    if session_id not in self.sessions:
        raise ValueError(f"Session {session_id} not found")

    session = self.sessions[session_id]

    try:
        # Close page
        if session.page:
            await session.page.close()

        # Close context (destroys all credentials in memory)
        if session.context:
            await session.context.close()

        logger.info(f"Closed browser session {session_id}")

    except Exception as e:
        logger.error(f"Error closing session {session_id}: {e}")

    finally:
        # Remove from sessions dict
        del self.sessions[session_id]

navigate(session_id, url, wait_for='load') async

Navigate to URL.

Parameters:

Name Type Description Default
session_id str

Session ID

required
url str

URL to navigate to

required
wait_for str

Wait condition ("load", "networkidle", "domcontentloaded")

'load'

Returns:

Type Description
dict[str, Any]

Navigation result with accessibility snapshot

Raises:

Type Description
ValueError

If session not found or navigation fails

Source code in src/harombe/security/browser_manager.py
async def navigate(
    self,
    session_id: str,
    url: str,
    wait_for: str = "load",
) -> dict[str, Any]:
    """Navigate to URL.

    Args:
        session_id: Session ID
        url: URL to navigate to
        wait_for: Wait condition ("load", "networkidle", "domcontentloaded")

    Returns:
        Navigation result with accessibility snapshot

    Raises:
        ValueError: If session not found or navigation fails
    """
    session = self._get_session(session_id)

    try:
        # Navigate
        await session.page.goto(url, wait_until=wait_for)

        # Update session activity
        session.last_activity = time.time()
        session.action_count += 1

        # Get accessibility snapshot
        snapshot = await self._get_accessibility_snapshot(session.page)

        logger.info(f"Navigated to {url} in session {session_id}")

        return {
            "success": True,
            "url": session.page.url,
            "title": await session.page.title(),
            "snapshot": snapshot,
        }

    except Exception as e:
        logger.error(f"Navigation failed for {url}: {e}")
        raise ValueError(f"Navigation failed: {e!s}") from e

get_session_info(session_id) async

Get session information.

Parameters:

Name Type Description Default
session_id str

Session ID

required

Returns:

Type Description
dict[str, Any]

Session information dict

Source code in src/harombe/security/browser_manager.py
async def get_session_info(self, session_id: str) -> dict[str, Any]:
    """Get session information.

    Args:
        session_id: Session ID

    Returns:
        Session information dict
    """
    session = self._get_session(session_id)

    return {
        "session_id": session.session_id,
        "domain": session.domain,
        "created_at": datetime.fromtimestamp(session.created_at, UTC).isoformat(),
        "last_activity": datetime.fromtimestamp(session.last_activity, UTC).isoformat(),
        "action_count": session.action_count,
        "url": session.page.url if session.page else None,
        "title": await session.page.title() if session.page else None,
    }

list_sessions() async

List all active sessions.

Returns:

Type Description
list[dict[str, Any]]

List of session info dicts

Source code in src/harombe/security/browser_manager.py
async def list_sessions(self) -> list[dict[str, Any]]:
    """List all active sessions.

    Returns:
        List of session info dicts
    """
    return [await self.get_session_info(session_id) for session_id in self.sessions]

BrowserCredentials dataclass

Credentials for browser pre-authentication.

Source code in src/harombe/security/browser_manager.py
@dataclass
class BrowserCredentials:
    """Credentials for browser pre-authentication."""

    domain: str
    cookies: list[dict[str, Any]] = field(default_factory=list)
    local_storage: dict[str, str] = field(default_factory=dict)
    session_storage: dict[str, str] = field(default_factory=dict)
    headers: dict[str, str] = field(default_factory=dict)

BrowserSession dataclass

Represents an active browser session.

Source code in src/harombe/security/browser_manager.py
@dataclass
class BrowserSession:
    """Represents an active browser session."""

    session_id: str
    domain: str
    context: BrowserContext | None = None
    page: Page | None = None
    created_at: float = field(default_factory=time.time)
    action_count: int = 0
    last_activity: float = field(default_factory=time.time)

ComplianceFramework

Bases: StrEnum

Supported compliance frameworks.

Source code in src/harombe/security/compliance_reports.py
class ComplianceFramework(StrEnum):
    """Supported compliance frameworks."""

    PCI_DSS = "pci_dss"
    GDPR = "gdpr"
    SOC2 = "soc2"

ComplianceReport

Bases: BaseModel

A complete compliance report.

Source code in src/harombe/security/compliance_reports.py
class ComplianceReport(BaseModel):
    """A complete compliance report."""

    report_id: str = Field(default_factory=lambda: f"report-{int(time.time() * 1000)}")
    framework: ComplianceFramework
    title: str
    generated_at: datetime = Field(default_factory=datetime.utcnow)
    period_start: datetime
    period_end: datetime
    sections: list[ReportSection] = Field(default_factory=list)
    summary: str = ""
    overall_status: ControlStatus = ControlStatus.PASS
    total_controls: int = 0
    controls_passed: int = 0
    controls_failed: int = 0
    controls_partial: int = 0
    findings: list[Finding] = Field(default_factory=list)
    metadata: dict[str, Any] = Field(default_factory=dict)

ComplianceReportGenerator

Generate compliance reports from audit data.

Supports PCI DSS, GDPR, and SOC 2 compliance frameworks. Queries the AuditDatabase for relevant data and generates structured reports with control assessments.

Usage

generator = ComplianceReportGenerator(audit_db) report = generator.generate( framework=ComplianceFramework.PCI_DSS, start=datetime(2026, 1, 1), end=datetime(2026, 2, 1), ) html = generator.export_html(report)

Source code in src/harombe/security/compliance_reports.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
class ComplianceReportGenerator:
    """Generate compliance reports from audit data.

    Supports PCI DSS, GDPR, and SOC 2 compliance frameworks.
    Queries the AuditDatabase for relevant data and generates
    structured reports with control assessments.

    Usage:
        generator = ComplianceReportGenerator(audit_db)
        report = generator.generate(
            framework=ComplianceFramework.PCI_DSS,
            start=datetime(2026, 1, 1),
            end=datetime(2026, 2, 1),
        )
        html = generator.export_html(report)
    """

    def __init__(self, audit_db: AuditDatabase):
        """Initialize compliance report generator.

        Args:
            audit_db: AuditDatabase instance for data queries
        """
        self.db = audit_db
        self.stats: dict[str, Any] = {
            "reports_generated": 0,
            "total_generation_time_ms": 0.0,
            "per_framework": {},
        }

    def generate(
        self,
        framework: ComplianceFramework,
        start: datetime,
        end: datetime,
    ) -> ComplianceReport:
        """Generate a compliance report.

        Args:
            framework: Compliance framework to report on
            start: Report period start
            end: Report period end

        Returns:
            ComplianceReport with sections, controls, and findings
        """
        gen_start = time.perf_counter()

        # Gather data from audit database
        stats = self.db.get_statistics(start_time=start, end_time=end)
        events = self.db.get_events_by_session(None, limit=10000)
        tool_calls = self.db.get_tool_calls(start_time=start, end_time=end, limit=10000)
        security_decisions = self.db.get_security_decisions(limit=10000)

        audit_data = {
            "stats": stats,
            "events": events,
            "tool_calls": tool_calls,
            "security_decisions": security_decisions,
            "period_start": start,
            "period_end": end,
        }

        # Generate framework-specific report
        if framework == ComplianceFramework.PCI_DSS:
            report = self._generate_pci_dss(audit_data, start, end)
        elif framework == ComplianceFramework.GDPR:
            report = self._generate_gdpr(audit_data, start, end)
        elif framework == ComplianceFramework.SOC2:
            report = self._generate_soc2(audit_data, start, end)
        else:
            raise ValueError(f"Unsupported framework: {framework}")

        # Compute summary stats
        report.total_controls = sum(len(s.controls) for s in report.sections)
        report.controls_passed = sum(
            1 for s in report.sections for c in s.controls if c.status == ControlStatus.PASS
        )
        report.controls_failed = sum(
            1 for s in report.sections for c in s.controls if c.status == ControlStatus.FAIL
        )
        report.controls_partial = sum(
            1 for s in report.sections for c in s.controls if c.status == ControlStatus.PARTIAL
        )
        report.findings = [f for s in report.sections for c in s.controls for f in c.findings]

        # Overall status
        if report.controls_failed > 0:
            report.overall_status = ControlStatus.FAIL
        elif report.controls_partial > 0:
            report.overall_status = ControlStatus.PARTIAL
        else:
            report.overall_status = ControlStatus.PASS

        report.summary = (
            f"{report.framework.value.upper()} Compliance Report: "
            f"{report.controls_passed}/{report.total_controls} controls passed, "
            f"{report.controls_failed} failed, {report.controls_partial} partial. "
            f"{len(report.findings)} findings."
        )

        # Update stats
        elapsed_ms = (time.perf_counter() - gen_start) * 1000
        self.stats["reports_generated"] += 1
        self.stats["total_generation_time_ms"] += elapsed_ms
        fw_key = framework.value
        if fw_key not in self.stats["per_framework"]:
            self.stats["per_framework"][fw_key] = {"count": 0, "avg_time_ms": 0.0}
        fw_stats = self.stats["per_framework"][fw_key]
        fw_stats["count"] += 1
        fw_stats["avg_time_ms"] += (elapsed_ms - fw_stats["avg_time_ms"]) / fw_stats["count"]

        return report

    def _generate_pci_dss(
        self,
        data: dict[str, Any],
        start: datetime,
        end: datetime,
    ) -> ComplianceReport:
        """Generate PCI DSS compliance report."""
        stats = data["stats"]
        events = data["events"]
        security_decisions = data["security_decisions"]

        sections = []

        # Requirement 3: Protect Stored Cardholder Data
        req3_controls = [
            _assess_control(
                "PCI-3.4",
                "Render PAN unreadable",
                "Sensitive data must be redacted in logs",
                {"events": events, "stats": stats},
                _check_data_redaction,
            ),
        ]
        sections.append(
            ReportSection(
                title="Requirement 3: Protect Stored Cardholder Data",
                description="Controls for protecting stored cardholder data",
                controls=req3_controls,
            )
        )

        # Requirement 7: Restrict Access by Business Need-to-Know
        req7_controls = [
            _assess_control(
                "PCI-7.1",
                "Limit access to system components",
                "Access must be restricted based on need-to-know",
                {"security_decisions": security_decisions},
                _check_access_controls,
            ),
        ]
        sections.append(
            ReportSection(
                title="Requirement 7: Restrict Access",
                description="Controls for access restriction",
                controls=req7_controls,
            )
        )

        # Requirement 10: Log and Monitor All Access
        req10_controls = [
            _assess_control(
                "PCI-10.1",
                "Audit trail implementation",
                "All access to system components must be logged",
                {"stats": stats},
                _check_audit_logging,
            ),
            _assess_control(
                "PCI-10.5",
                "Secure audit trails",
                "Audit trails must be secured against unauthorized modification",
                {"stats": stats},
                _check_audit_integrity,
            ),
        ]
        sections.append(
            ReportSection(
                title="Requirement 10: Log and Monitor All Access",
                description="Controls for logging and monitoring",
                controls=req10_controls,
            )
        )

        return ComplianceReport(
            framework=ComplianceFramework.PCI_DSS,
            title="PCI DSS Compliance Report",
            period_start=start,
            period_end=end,
            sections=sections,
        )

    def _generate_gdpr(
        self,
        data: dict[str, Any],
        start: datetime,
        end: datetime,
    ) -> ComplianceReport:
        """Generate GDPR compliance report."""
        stats = data["stats"]
        events = data["events"]
        security_decisions = data["security_decisions"]

        sections = []

        # Article 5: Principles relating to processing
        art5_controls = [
            _assess_control(
                "GDPR-5.1f",
                "Integrity and confidentiality",
                "Personal data must be processed with appropriate security",
                {"events": events, "stats": stats},
                _check_data_redaction,
            ),
        ]
        sections.append(
            ReportSection(
                title="Article 5: Data Processing Principles",
                description="Principles for lawful processing of personal data",
                controls=art5_controls,
            )
        )

        # Article 25: Data protection by design
        art25_controls = [
            _assess_control(
                "GDPR-25.1",
                "Data protection by design and default",
                "Implement appropriate technical measures for data protection",
                {"security_decisions": security_decisions},
                _check_access_controls,
            ),
        ]
        sections.append(
            ReportSection(
                title="Article 25: Data Protection by Design",
                description="Technical and organizational measures for data protection",
                controls=art25_controls,
            )
        )

        # Article 30: Records of processing activities
        art30_controls = [
            _assess_control(
                "GDPR-30.1",
                "Records of processing activities",
                "Maintain records of all data processing activities",
                {"stats": stats},
                _check_audit_logging,
            ),
        ]
        sections.append(
            ReportSection(
                title="Article 30: Records of Processing Activities",
                description="Maintaining records of processing activities",
                controls=art30_controls,
            )
        )

        # Article 32: Security of processing
        art32_controls = [
            _assess_control(
                "GDPR-32.1",
                "Security of processing",
                "Implement security measures appropriate to the risk",
                {"security_decisions": security_decisions, "stats": stats},
                _check_security_decisions,
            ),
        ]
        sections.append(
            ReportSection(
                title="Article 32: Security of Processing",
                description="Implementing appropriate security measures",
                controls=art32_controls,
            )
        )

        return ComplianceReport(
            framework=ComplianceFramework.GDPR,
            title="GDPR Compliance Report",
            period_start=start,
            period_end=end,
            sections=sections,
        )

    def _generate_soc2(
        self,
        data: dict[str, Any],
        start: datetime,
        end: datetime,
    ) -> ComplianceReport:
        """Generate SOC 2 compliance report."""
        stats = data["stats"]
        events = data["events"]
        security_decisions = data["security_decisions"]
        tool_calls = data["tool_calls"]

        sections = []

        # CC6: Logical and Physical Access Controls
        cc6_controls = [
            _assess_control(
                "CC6.1",
                "Logical access security",
                "Implement logical access controls over information assets",
                {"security_decisions": security_decisions},
                _check_access_controls,
            ),
            _assess_control(
                "CC6.3",
                "Access authorization",
                "Authorize access based on business need",
                {"security_decisions": security_decisions},
                _check_authorization,
            ),
        ]
        sections.append(
            ReportSection(
                title="CC6: Logical and Physical Access Controls",
                description="Controls for securing logical and physical access",
                controls=cc6_controls,
            )
        )

        # CC7: System Operations
        cc7_controls = [
            _assess_control(
                "CC7.1",
                "Monitoring of infrastructure",
                "Monitor system infrastructure and operations",
                {"stats": stats},
                _check_audit_logging,
            ),
            _assess_control(
                "CC7.2",
                "Anomaly detection",
                "Detect and respond to anomalies",
                {"events": events},
                _check_anomaly_detection,
            ),
        ]
        sections.append(
            ReportSection(
                title="CC7: System Operations",
                description="Controls for system monitoring and operations",
                controls=cc7_controls,
            )
        )

        # CC8: Change Management
        cc8_controls = [
            _assess_control(
                "CC8.1",
                "Change management process",
                "Changes must follow an authorized process",
                {"tool_calls": tool_calls, "stats": stats},
                _check_change_management,
            ),
        ]
        sections.append(
            ReportSection(
                title="CC8: Change Management",
                description="Controls for managing system changes",
                controls=cc8_controls,
            )
        )

        return ComplianceReport(
            framework=ComplianceFramework.SOC2,
            title="SOC 2 Type II Compliance Report",
            period_start=start,
            period_end=end,
            sections=sections,
        )

    def export_html(self, report: ComplianceReport) -> str:
        """Export report as HTML string.

        Args:
            report: ComplianceReport to export

        Returns:
            HTML string
        """
        status_colors = {
            ControlStatus.PASS: "#28a745",
            ControlStatus.FAIL: "#dc3545",
            ControlStatus.PARTIAL: "#ffc107",
            ControlStatus.NOT_APPLICABLE: "#6c757d",
        }

        html_parts = [
            "<!DOCTYPE html>",
            "<html><head>",
            f"<title>{report.title}</title>",
            "<style>",
            "body { font-family: Arial, sans-serif; margin: 40px; }",
            "h1 { color: #333; } h2 { color: #555; } h3 { color: #777; }",
            "table { border-collapse: collapse; width: 100%; margin: 10px 0; }",
            "th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }",
            "th { background-color: #f5f5f5; }",
            ".status-pass { color: #28a745; font-weight: bold; }",
            ".status-fail { color: #dc3545; font-weight: bold; }",
            ".status-partial { color: #ffc107; font-weight: bold; }",
            ".finding { background-color: #fff3cd; padding: 10px; margin: 5px 0; border-radius: 4px; }",
            "</style>",
            "</head><body>",
            f"<h1>{report.title}</h1>",
            f"<p>Generated: {report.generated_at.isoformat()}</p>",
            f"<p>Period: {report.period_start.isoformat()} to {report.period_end.isoformat()}</p>",
            f"<p><strong>Overall Status: "
            f"<span style='color:{status_colors.get(report.overall_status, '#333')}'>"
            f"{report.overall_status.value.upper()}</span></strong></p>",
            f"<p>{report.summary}</p>",
            "<hr>",
        ]

        for section in report.sections:
            html_parts.append(f"<h2>{section.title}</h2>")
            if section.description:
                html_parts.append(f"<p>{section.description}</p>")

            html_parts.append(
                "<table><tr><th>Control</th><th>Name</th><th>Status</th><th>Evidence</th></tr>"
            )
            for control in section.controls:
                status_class = f"status-{control.status.value}"
                html_parts.append(
                    f"<tr><td>{control.control_id}</td>"
                    f"<td>{control.control_name}</td>"
                    f"<td class='{status_class}'>{control.status.value.upper()}</td>"
                    f"<td>{control.evidence_summary}</td></tr>"
                )
            html_parts.append("</table>")

            for control in section.controls:
                for finding in control.findings:
                    html_parts.append(
                        f"<div class='finding'>"
                        f"<strong>[{finding.severity.upper()}] {finding.title}</strong>"
                        f"<p>{finding.description}</p>"
                        f"{'<p><em>Recommendation: ' + finding.recommendation + '</em></p>' if finding.recommendation else ''}"
                        f"</div>"
                    )

        html_parts.extend(
            [
                "<hr>",
                f"<p><small>Report ID: {report.report_id}</small></p>",
                "</body></html>",
            ]
        )

        return "\n".join(html_parts)

    def export_json(self, report: ComplianceReport) -> str:
        """Export report as JSON string.

        Args:
            report: ComplianceReport to export

        Returns:
            JSON string
        """
        return report.model_dump_json(indent=2)

    def get_stats(self) -> dict[str, Any]:
        """Get generator statistics."""
        return dict(self.stats)

__init__(audit_db)

Initialize compliance report generator.

Parameters:

Name Type Description Default
audit_db AuditDatabase

AuditDatabase instance for data queries

required
Source code in src/harombe/security/compliance_reports.py
def __init__(self, audit_db: AuditDatabase):
    """Initialize compliance report generator.

    Args:
        audit_db: AuditDatabase instance for data queries
    """
    self.db = audit_db
    self.stats: dict[str, Any] = {
        "reports_generated": 0,
        "total_generation_time_ms": 0.0,
        "per_framework": {},
    }

generate(framework, start, end)

Generate a compliance report.

Parameters:

Name Type Description Default
framework ComplianceFramework

Compliance framework to report on

required
start datetime

Report period start

required
end datetime

Report period end

required

Returns:

Type Description
ComplianceReport

ComplianceReport with sections, controls, and findings

Source code in src/harombe/security/compliance_reports.py
def generate(
    self,
    framework: ComplianceFramework,
    start: datetime,
    end: datetime,
) -> ComplianceReport:
    """Generate a compliance report.

    Args:
        framework: Compliance framework to report on
        start: Report period start
        end: Report period end

    Returns:
        ComplianceReport with sections, controls, and findings
    """
    gen_start = time.perf_counter()

    # Gather data from audit database
    stats = self.db.get_statistics(start_time=start, end_time=end)
    events = self.db.get_events_by_session(None, limit=10000)
    tool_calls = self.db.get_tool_calls(start_time=start, end_time=end, limit=10000)
    security_decisions = self.db.get_security_decisions(limit=10000)

    audit_data = {
        "stats": stats,
        "events": events,
        "tool_calls": tool_calls,
        "security_decisions": security_decisions,
        "period_start": start,
        "period_end": end,
    }

    # Generate framework-specific report
    if framework == ComplianceFramework.PCI_DSS:
        report = self._generate_pci_dss(audit_data, start, end)
    elif framework == ComplianceFramework.GDPR:
        report = self._generate_gdpr(audit_data, start, end)
    elif framework == ComplianceFramework.SOC2:
        report = self._generate_soc2(audit_data, start, end)
    else:
        raise ValueError(f"Unsupported framework: {framework}")

    # Compute summary stats
    report.total_controls = sum(len(s.controls) for s in report.sections)
    report.controls_passed = sum(
        1 for s in report.sections for c in s.controls if c.status == ControlStatus.PASS
    )
    report.controls_failed = sum(
        1 for s in report.sections for c in s.controls if c.status == ControlStatus.FAIL
    )
    report.controls_partial = sum(
        1 for s in report.sections for c in s.controls if c.status == ControlStatus.PARTIAL
    )
    report.findings = [f for s in report.sections for c in s.controls for f in c.findings]

    # Overall status
    if report.controls_failed > 0:
        report.overall_status = ControlStatus.FAIL
    elif report.controls_partial > 0:
        report.overall_status = ControlStatus.PARTIAL
    else:
        report.overall_status = ControlStatus.PASS

    report.summary = (
        f"{report.framework.value.upper()} Compliance Report: "
        f"{report.controls_passed}/{report.total_controls} controls passed, "
        f"{report.controls_failed} failed, {report.controls_partial} partial. "
        f"{len(report.findings)} findings."
    )

    # Update stats
    elapsed_ms = (time.perf_counter() - gen_start) * 1000
    self.stats["reports_generated"] += 1
    self.stats["total_generation_time_ms"] += elapsed_ms
    fw_key = framework.value
    if fw_key not in self.stats["per_framework"]:
        self.stats["per_framework"][fw_key] = {"count": 0, "avg_time_ms": 0.0}
    fw_stats = self.stats["per_framework"][fw_key]
    fw_stats["count"] += 1
    fw_stats["avg_time_ms"] += (elapsed_ms - fw_stats["avg_time_ms"]) / fw_stats["count"]

    return report

export_html(report)

Export report as HTML string.

Parameters:

Name Type Description Default
report ComplianceReport

ComplianceReport to export

required

Returns:

Type Description
str

HTML string

Source code in src/harombe/security/compliance_reports.py
def export_html(self, report: ComplianceReport) -> str:
    """Export report as HTML string.

    Args:
        report: ComplianceReport to export

    Returns:
        HTML string
    """
    status_colors = {
        ControlStatus.PASS: "#28a745",
        ControlStatus.FAIL: "#dc3545",
        ControlStatus.PARTIAL: "#ffc107",
        ControlStatus.NOT_APPLICABLE: "#6c757d",
    }

    html_parts = [
        "<!DOCTYPE html>",
        "<html><head>",
        f"<title>{report.title}</title>",
        "<style>",
        "body { font-family: Arial, sans-serif; margin: 40px; }",
        "h1 { color: #333; } h2 { color: #555; } h3 { color: #777; }",
        "table { border-collapse: collapse; width: 100%; margin: 10px 0; }",
        "th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }",
        "th { background-color: #f5f5f5; }",
        ".status-pass { color: #28a745; font-weight: bold; }",
        ".status-fail { color: #dc3545; font-weight: bold; }",
        ".status-partial { color: #ffc107; font-weight: bold; }",
        ".finding { background-color: #fff3cd; padding: 10px; margin: 5px 0; border-radius: 4px; }",
        "</style>",
        "</head><body>",
        f"<h1>{report.title}</h1>",
        f"<p>Generated: {report.generated_at.isoformat()}</p>",
        f"<p>Period: {report.period_start.isoformat()} to {report.period_end.isoformat()}</p>",
        f"<p><strong>Overall Status: "
        f"<span style='color:{status_colors.get(report.overall_status, '#333')}'>"
        f"{report.overall_status.value.upper()}</span></strong></p>",
        f"<p>{report.summary}</p>",
        "<hr>",
    ]

    for section in report.sections:
        html_parts.append(f"<h2>{section.title}</h2>")
        if section.description:
            html_parts.append(f"<p>{section.description}</p>")

        html_parts.append(
            "<table><tr><th>Control</th><th>Name</th><th>Status</th><th>Evidence</th></tr>"
        )
        for control in section.controls:
            status_class = f"status-{control.status.value}"
            html_parts.append(
                f"<tr><td>{control.control_id}</td>"
                f"<td>{control.control_name}</td>"
                f"<td class='{status_class}'>{control.status.value.upper()}</td>"
                f"<td>{control.evidence_summary}</td></tr>"
            )
        html_parts.append("</table>")

        for control in section.controls:
            for finding in control.findings:
                html_parts.append(
                    f"<div class='finding'>"
                    f"<strong>[{finding.severity.upper()}] {finding.title}</strong>"
                    f"<p>{finding.description}</p>"
                    f"{'<p><em>Recommendation: ' + finding.recommendation + '</em></p>' if finding.recommendation else ''}"
                    f"</div>"
                )

    html_parts.extend(
        [
            "<hr>",
            f"<p><small>Report ID: {report.report_id}</small></p>",
            "</body></html>",
        ]
    )

    return "\n".join(html_parts)

export_json(report)

Export report as JSON string.

Parameters:

Name Type Description Default
report ComplianceReport

ComplianceReport to export

required

Returns:

Type Description
str

JSON string

Source code in src/harombe/security/compliance_reports.py
def export_json(self, report: ComplianceReport) -> str:
    """Export report as JSON string.

    Args:
        report: ComplianceReport to export

    Returns:
        JSON string
    """
    return report.model_dump_json(indent=2)

get_stats()

Get generator statistics.

Source code in src/harombe/security/compliance_reports.py
def get_stats(self) -> dict[str, Any]:
    """Get generator statistics."""
    return dict(self.stats)

ControlAssessment

Bases: BaseModel

Assessment of a single compliance control.

Source code in src/harombe/security/compliance_reports.py
class ControlAssessment(BaseModel):
    """Assessment of a single compliance control."""

    control_id: str
    control_name: str
    description: str = ""
    status: ControlStatus = ControlStatus.PASS
    findings: list[Finding] = Field(default_factory=list)
    evidence_summary: str = ""
    data: dict[str, Any] = Field(default_factory=dict)

ControlStatus

Bases: StrEnum

Status of a compliance control.

Source code in src/harombe/security/compliance_reports.py
class ControlStatus(StrEnum):
    """Status of a compliance control."""

    PASS = "pass"
    FAIL = "fail"
    PARTIAL = "partial"
    NOT_APPLICABLE = "not_applicable"

Finding

Bases: BaseModel

A compliance finding/observation.

Source code in src/harombe/security/compliance_reports.py
class Finding(BaseModel):
    """A compliance finding/observation."""

    title: str
    description: str
    severity: str = "info"  # "info", "low", "medium", "high", "critical"
    control_id: str = ""
    recommendation: str = ""
    evidence: dict[str, Any] = Field(default_factory=dict)

ReportSection

Bases: BaseModel

A section within a compliance report.

Source code in src/harombe/security/compliance_reports.py
class ReportSection(BaseModel):
    """A section within a compliance report."""

    title: str
    description: str = ""
    controls: list[ControlAssessment] = Field(default_factory=list)
    summary: str = ""
    data: dict[str, Any] = Field(default_factory=dict)

DashboardMetrics

Bases: BaseModel

Complete set of dashboard metrics.

Source code in src/harombe/security/dashboard.py
class DashboardMetrics(BaseModel):
    """Complete set of dashboard metrics."""

    timestamp: datetime = Field(default_factory=datetime.utcnow)

    # Activity metrics
    events_last_hour: int = 0
    events_last_day: int = 0
    active_sessions: int = 0
    active_actors: int = 0

    # Security metrics
    security_denials: int = 0
    security_allows: int = 0
    error_events: int = 0
    tool_call_errors: int = 0

    # Performance metrics
    avg_tool_duration_ms: float = 0.0
    total_tool_calls: int = 0

    # Derived metrics
    denial_rate: float = 0.0
    error_rate: float = 0.0

    def to_metric_list(self) -> list[MetricValue]:
        """Convert to a list of MetricValue objects for API/WebSocket consumption."""
        return [
            MetricValue(
                name="events_last_hour",
                value=self.events_last_hour,
                unit="count",
                category="activity",
                description="Audit events in the last hour",
            ),
            MetricValue(
                name="events_last_day",
                value=self.events_last_day,
                unit="count",
                category="activity",
                description="Audit events in the last 24 hours",
            ),
            MetricValue(
                name="active_sessions",
                value=self.active_sessions,
                unit="count",
                category="activity",
                description="Unique sessions in the last hour",
            ),
            MetricValue(
                name="active_actors",
                value=self.active_actors,
                unit="count",
                category="activity",
                description="Unique actors in the last hour",
            ),
            MetricValue(
                name="security_denials",
                value=self.security_denials,
                unit="count",
                category="security",
                description="Security decision denials in the last 24 hours",
            ),
            MetricValue(
                name="security_allows",
                value=self.security_allows,
                unit="count",
                category="security",
                description="Security decision allows in the last 24 hours",
            ),
            MetricValue(
                name="denial_rate",
                value=self.denial_rate,
                unit="percent",
                category="security",
                description="Percentage of denied security decisions",
            ),
            MetricValue(
                name="error_events",
                value=self.error_events,
                unit="count",
                category="security",
                description="Error events in the last 24 hours",
            ),
            MetricValue(
                name="tool_call_errors",
                value=self.tool_call_errors,
                unit="count",
                category="security",
                description="Tool call errors in the last 24 hours",
            ),
            MetricValue(
                name="error_rate",
                value=self.error_rate,
                unit="percent",
                category="security",
                description="Percentage of events that are errors",
            ),
            MetricValue(
                name="avg_tool_duration_ms",
                value=self.avg_tool_duration_ms,
                unit="ms",
                category="performance",
                description="Average tool call duration",
            ),
            MetricValue(
                name="total_tool_calls",
                value=self.total_tool_calls,
                unit="count",
                category="performance",
                description="Total tool calls in the last 24 hours",
            ),
        ]

to_metric_list()

Convert to a list of MetricValue objects for API/WebSocket consumption.

Source code in src/harombe/security/dashboard.py
def to_metric_list(self) -> list[MetricValue]:
    """Convert to a list of MetricValue objects for API/WebSocket consumption."""
    return [
        MetricValue(
            name="events_last_hour",
            value=self.events_last_hour,
            unit="count",
            category="activity",
            description="Audit events in the last hour",
        ),
        MetricValue(
            name="events_last_day",
            value=self.events_last_day,
            unit="count",
            category="activity",
            description="Audit events in the last 24 hours",
        ),
        MetricValue(
            name="active_sessions",
            value=self.active_sessions,
            unit="count",
            category="activity",
            description="Unique sessions in the last hour",
        ),
        MetricValue(
            name="active_actors",
            value=self.active_actors,
            unit="count",
            category="activity",
            description="Unique actors in the last hour",
        ),
        MetricValue(
            name="security_denials",
            value=self.security_denials,
            unit="count",
            category="security",
            description="Security decision denials in the last 24 hours",
        ),
        MetricValue(
            name="security_allows",
            value=self.security_allows,
            unit="count",
            category="security",
            description="Security decision allows in the last 24 hours",
        ),
        MetricValue(
            name="denial_rate",
            value=self.denial_rate,
            unit="percent",
            category="security",
            description="Percentage of denied security decisions",
        ),
        MetricValue(
            name="error_events",
            value=self.error_events,
            unit="count",
            category="security",
            description="Error events in the last 24 hours",
        ),
        MetricValue(
            name="tool_call_errors",
            value=self.tool_call_errors,
            unit="count",
            category="security",
            description="Tool call errors in the last 24 hours",
        ),
        MetricValue(
            name="error_rate",
            value=self.error_rate,
            unit="percent",
            category="security",
            description="Percentage of events that are errors",
        ),
        MetricValue(
            name="avg_tool_duration_ms",
            value=self.avg_tool_duration_ms,
            unit="ms",
            category="performance",
            description="Average tool call duration",
        ),
        MetricValue(
            name="total_tool_calls",
            value=self.total_tool_calls,
            unit="count",
            category="performance",
            description="Total tool calls in the last 24 hours",
        ),
    ]

MetricsCache

Simple TTL-based metrics cache.

Source code in src/harombe/security/dashboard.py
class MetricsCache:
    """Simple TTL-based metrics cache."""

    def __init__(self, ttl_seconds: float = 60.0):
        self.ttl_seconds = ttl_seconds
        self._cache: dict[str, tuple[float, Any]] = {}

    def get(self, key: str) -> Any | None:
        """Get a cached value if not expired."""
        if key in self._cache:
            timestamp, value = self._cache[key]
            if time.time() - timestamp < self.ttl_seconds:
                return value
            del self._cache[key]
        return None

    def set(self, key: str, value: Any) -> None:
        """Set a cached value."""
        self._cache[key] = (time.time(), value)

    def invalidate(self, key: str | None = None) -> None:
        """Invalidate a specific key or all keys."""
        if key is None:
            self._cache.clear()
        else:
            self._cache.pop(key, None)

    @property
    def size(self) -> int:
        """Number of cached items."""
        return len(self._cache)

size property

Number of cached items.

get(key)

Get a cached value if not expired.

Source code in src/harombe/security/dashboard.py
def get(self, key: str) -> Any | None:
    """Get a cached value if not expired."""
    if key in self._cache:
        timestamp, value = self._cache[key]
        if time.time() - timestamp < self.ttl_seconds:
            return value
        del self._cache[key]
    return None

set(key, value)

Set a cached value.

Source code in src/harombe/security/dashboard.py
def set(self, key: str, value: Any) -> None:
    """Set a cached value."""
    self._cache[key] = (time.time(), value)

invalidate(key=None)

Invalidate a specific key or all keys.

Source code in src/harombe/security/dashboard.py
def invalidate(self, key: str | None = None) -> None:
    """Invalidate a specific key or all keys."""
    if key is None:
        self._cache.clear()
    else:
        self._cache.pop(key, None)

MetricTrend

Bases: BaseModel

A time series trend for a metric.

Source code in src/harombe/security/dashboard.py
class MetricTrend(BaseModel):
    """A time series trend for a metric."""

    metric_name: str
    points: list[TrendPoint] = Field(default_factory=list)
    period_hours: int = 24

MetricValue

Bases: BaseModel

A single metric value with metadata.

Source code in src/harombe/security/dashboard.py
class MetricValue(BaseModel):
    """A single metric value with metadata."""

    name: str
    value: float | int
    unit: str = ""  # "count", "ms", "percent", etc.
    category: str = "activity"  # "activity", "security", "performance"
    description: str = ""

SecurityDashboard

Real-time security metrics dashboard.

Computes metrics from the AuditDatabase with caching for performance. Provides WebSocket-ready data snapshots and trend calculations.

Usage

dashboard = SecurityDashboard(audit_db)

Get current metrics

metrics = dashboard.get_metrics()

Get as list for API/WebSocket

metric_list = metrics.to_metric_list()

trend = dashboard.get_trend("events", hours=24)

Source code in src/harombe/security/dashboard.py
class SecurityDashboard:
    """Real-time security metrics dashboard.

    Computes metrics from the AuditDatabase with caching for performance.
    Provides WebSocket-ready data snapshots and trend calculations.

    Usage:
        dashboard = SecurityDashboard(audit_db)

        # Get current metrics
        metrics = dashboard.get_metrics()

        # Get as list for API/WebSocket
        metric_list = metrics.to_metric_list()

        # Get trends
        trend = dashboard.get_trend("events", hours=24)
    """

    def __init__(
        self,
        audit_db: AuditDatabase,
        cache_ttl_seconds: float = 60.0,
    ):
        """Initialize dashboard.

        Args:
            audit_db: AuditDatabase instance
            cache_ttl_seconds: Metrics cache TTL in seconds
        """
        self.db = audit_db
        self.cache = MetricsCache(ttl_seconds=cache_ttl_seconds)
        self.stats: dict[str, Any] = {
            "metrics_computed": 0,
            "cache_hits": 0,
            "cache_misses": 0,
            "avg_computation_ms": 0.0,
        }

    def get_metrics(self) -> DashboardMetrics:
        """Get current security metrics.

        Uses cache if available, otherwise computes from database.

        Returns:
            DashboardMetrics with current values
        """
        cached = self.cache.get("current_metrics")
        if cached is not None:
            self.stats["cache_hits"] += 1
            return cached

        self.stats["cache_misses"] += 1
        start = time.perf_counter()

        metrics = self._compute_metrics()

        elapsed_ms = (time.perf_counter() - start) * 1000
        self.stats["metrics_computed"] += 1
        total = self.stats["metrics_computed"]
        prev_avg = self.stats["avg_computation_ms"]
        self.stats["avg_computation_ms"] = prev_avg + (elapsed_ms - prev_avg) / total

        self.cache.set("current_metrics", metrics)
        return metrics

    def _compute_metrics(self) -> DashboardMetrics:
        """Compute all dashboard metrics from the database."""
        now = datetime.utcnow()
        one_hour_ago = now - timedelta(hours=1)
        one_day_ago = now - timedelta(hours=24)

        # Get statistics from audit database
        hour_stats = self.db.get_statistics(start_time=one_hour_ago, end_time=now)
        day_stats = self.db.get_statistics(start_time=one_day_ago, end_time=now)

        # Activity metrics
        hour_events = hour_stats.get("events", {})
        day_events = day_stats.get("events", {})

        events_last_hour = hour_events.get("total_events", 0)
        events_last_day = day_events.get("total_events", 0)
        active_sessions = hour_events.get("unique_sessions", 0)
        active_actors = hour_events.get("unique_requests", 0)  # Approximation

        # Security decisions
        day_decisions = day_stats.get("security_decisions", [])
        security_denials = 0
        security_allows = 0
        for dec in day_decisions:
            if dec.get("decision") == "deny":
                security_denials += dec.get("count", 0)
            elif dec.get("decision") == "allow":
                security_allows += dec.get("count", 0)

        total_decisions = security_denials + security_allows
        denial_rate = (security_denials / total_decisions * 100) if total_decisions > 0 else 0.0

        # Error metrics (approximate from events)
        # Count error events by getting events list
        all_events = self.db.get_events_by_session(None, limit=10000)
        day_events_list = [
            e
            for e in all_events
            if _parse_timestamp(e.get("timestamp"))
            and _parse_timestamp(e.get("timestamp")) >= one_day_ago
        ]
        error_events = sum(1 for e in day_events_list if e.get("event_type") == "error")
        error_rate = (error_events / len(day_events_list) * 100) if day_events_list else 0.0

        # Tool call metrics
        day_tools = day_stats.get("tools", [])
        total_tool_calls = sum(t.get("call_count", 0) for t in day_tools)
        tool_call_errors = 0
        total_duration = 0.0
        for tool in day_tools:
            avg_dur = tool.get("avg_duration_ms")
            count = tool.get("call_count", 0)
            if avg_dur is not None and count > 0:
                total_duration += avg_dur * count

        avg_tool_duration_ms = (total_duration / total_tool_calls) if total_tool_calls > 0 else 0.0

        # Count errored tool calls
        tool_calls_list = self.db.get_tool_calls(start_time=one_day_ago, end_time=now, limit=10000)
        tool_call_errors = sum(1 for tc in tool_calls_list if tc.get("error"))

        return DashboardMetrics(
            events_last_hour=events_last_hour,
            events_last_day=events_last_day,
            active_sessions=active_sessions,
            active_actors=active_actors,
            security_denials=security_denials,
            security_allows=security_allows,
            denial_rate=round(denial_rate, 1),
            error_events=error_events,
            tool_call_errors=tool_call_errors,
            error_rate=round(error_rate, 1),
            avg_tool_duration_ms=round(avg_tool_duration_ms, 1),
            total_tool_calls=total_tool_calls,
        )

    def get_trend(self, metric_name: str, hours: int = 24) -> MetricTrend:
        """Get a time series trend for a metric.

        Args:
            metric_name: Name of the metric (e.g., "events", "errors")
            hours: Number of hours to include

        Returns:
            MetricTrend with hourly data points
        """
        cache_key = f"trend_{metric_name}_{hours}"
        cached = self.cache.get(cache_key)
        if cached is not None:
            self.stats["cache_hits"] += 1
            return cached

        self.stats["cache_misses"] += 1
        trend = self._compute_trend(metric_name, hours)
        self.cache.set(cache_key, trend)
        return trend

    def _compute_trend(self, metric_name: str, hours: int) -> MetricTrend:
        """Compute hourly trend for a metric."""
        now = datetime.utcnow()
        points: list[TrendPoint] = []

        for hour_offset in range(hours, 0, -1):
            start = now - timedelta(hours=hour_offset)
            end = now - timedelta(hours=hour_offset - 1)

            stats = self.db.get_statistics(start_time=start, end_time=end)

            if metric_name == "events":
                value = stats.get("events", {}).get("total_events", 0)
            elif metric_name == "errors":
                # Count error events for this hour
                events = self.db.get_events_by_session(None, limit=10000)
                value = sum(
                    1
                    for e in events
                    if e.get("event_type") == "error"
                    and _parse_timestamp(e.get("timestamp"))
                    and start <= _parse_timestamp(e.get("timestamp")) < end
                )
            elif metric_name == "denials":
                decisions = stats.get("security_decisions", [])
                value = sum(d.get("count", 0) for d in decisions if d.get("decision") == "deny")
            elif metric_name == "tool_calls":
                tools = stats.get("tools", [])
                value = sum(t.get("call_count", 0) for t in tools)
            else:
                value = 0

            points.append(TrendPoint(timestamp=start, value=value))

        return MetricTrend(
            metric_name=metric_name,
            points=points,
            period_hours=hours,
        )

    def get_snapshot(self) -> dict[str, Any]:
        """Get a WebSocket-ready snapshot of all dashboard data.

        Returns a dictionary suitable for JSON serialization and
        WebSocket transmission.

        Returns:
            Dictionary with metrics and metadata
        """
        metrics = self.get_metrics()
        metric_list = metrics.to_metric_list()

        return {
            "timestamp": metrics.timestamp.isoformat() + "Z",
            "metrics": {m.name: m.model_dump(mode="json") for m in metric_list},
            "summary": {
                "total_events_24h": metrics.events_last_day,
                "error_rate": metrics.error_rate,
                "denial_rate": metrics.denial_rate,
                "active_sessions": metrics.active_sessions,
            },
        }

    def invalidate_cache(self) -> None:
        """Force cache invalidation for next refresh."""
        self.cache.invalidate()

    def get_stats(self) -> dict[str, Any]:
        """Get dashboard statistics."""
        return dict(self.stats)

__init__(audit_db, cache_ttl_seconds=60.0)

Initialize dashboard.

Parameters:

Name Type Description Default
audit_db AuditDatabase

AuditDatabase instance

required
cache_ttl_seconds float

Metrics cache TTL in seconds

60.0
Source code in src/harombe/security/dashboard.py
def __init__(
    self,
    audit_db: AuditDatabase,
    cache_ttl_seconds: float = 60.0,
):
    """Initialize dashboard.

    Args:
        audit_db: AuditDatabase instance
        cache_ttl_seconds: Metrics cache TTL in seconds
    """
    self.db = audit_db
    self.cache = MetricsCache(ttl_seconds=cache_ttl_seconds)
    self.stats: dict[str, Any] = {
        "metrics_computed": 0,
        "cache_hits": 0,
        "cache_misses": 0,
        "avg_computation_ms": 0.0,
    }

get_metrics()

Get current security metrics.

Uses cache if available, otherwise computes from database.

Returns:

Type Description
DashboardMetrics

DashboardMetrics with current values

Source code in src/harombe/security/dashboard.py
def get_metrics(self) -> DashboardMetrics:
    """Get current security metrics.

    Uses cache if available, otherwise computes from database.

    Returns:
        DashboardMetrics with current values
    """
    cached = self.cache.get("current_metrics")
    if cached is not None:
        self.stats["cache_hits"] += 1
        return cached

    self.stats["cache_misses"] += 1
    start = time.perf_counter()

    metrics = self._compute_metrics()

    elapsed_ms = (time.perf_counter() - start) * 1000
    self.stats["metrics_computed"] += 1
    total = self.stats["metrics_computed"]
    prev_avg = self.stats["avg_computation_ms"]
    self.stats["avg_computation_ms"] = prev_avg + (elapsed_ms - prev_avg) / total

    self.cache.set("current_metrics", metrics)
    return metrics

get_trend(metric_name, hours=24)

Get a time series trend for a metric.

Parameters:

Name Type Description Default
metric_name str

Name of the metric (e.g., "events", "errors")

required
hours int

Number of hours to include

24

Returns:

Type Description
MetricTrend

MetricTrend with hourly data points

Source code in src/harombe/security/dashboard.py
def get_trend(self, metric_name: str, hours: int = 24) -> MetricTrend:
    """Get a time series trend for a metric.

    Args:
        metric_name: Name of the metric (e.g., "events", "errors")
        hours: Number of hours to include

    Returns:
        MetricTrend with hourly data points
    """
    cache_key = f"trend_{metric_name}_{hours}"
    cached = self.cache.get(cache_key)
    if cached is not None:
        self.stats["cache_hits"] += 1
        return cached

    self.stats["cache_misses"] += 1
    trend = self._compute_trend(metric_name, hours)
    self.cache.set(cache_key, trend)
    return trend

get_snapshot()

Get a WebSocket-ready snapshot of all dashboard data.

Returns a dictionary suitable for JSON serialization and WebSocket transmission.

Returns:

Type Description
dict[str, Any]

Dictionary with metrics and metadata

Source code in src/harombe/security/dashboard.py
def get_snapshot(self) -> dict[str, Any]:
    """Get a WebSocket-ready snapshot of all dashboard data.

    Returns a dictionary suitable for JSON serialization and
    WebSocket transmission.

    Returns:
        Dictionary with metrics and metadata
    """
    metrics = self.get_metrics()
    metric_list = metrics.to_metric_list()

    return {
        "timestamp": metrics.timestamp.isoformat() + "Z",
        "metrics": {m.name: m.model_dump(mode="json") for m in metric_list},
        "summary": {
            "total_events_24h": metrics.events_last_day,
            "error_rate": metrics.error_rate,
            "denial_rate": metrics.denial_rate,
            "active_sessions": metrics.active_sessions,
        },
    }

invalidate_cache()

Force cache invalidation for next refresh.

Source code in src/harombe/security/dashboard.py
def invalidate_cache(self) -> None:
    """Force cache invalidation for next refresh."""
    self.cache.invalidate()

get_stats()

Get dashboard statistics.

Source code in src/harombe/security/dashboard.py
def get_stats(self) -> dict[str, Any]:
    """Get dashboard statistics."""
    return dict(self.stats)

TrendPoint

Bases: BaseModel

A single point in a time series trend.

Source code in src/harombe/security/dashboard.py
class TrendPoint(BaseModel):
    """A single point in a time series trend."""

    timestamp: datetime
    value: float | int

DockerManager

Manages Docker containers for MCP capability isolation.

Source code in src/harombe/security/docker_manager.py
class DockerManager:
    """Manages Docker containers for MCP capability isolation."""

    def __init__(self) -> None:
        """Initialize Docker manager."""
        self._docker: Any = None  # docker.DockerClient
        self._containers: dict[str, Any] = {}  # name -> container object

    def _get_client(self) -> Any:
        """Get or create Docker client.

        Returns:
            Docker client instance

        Raises:
            ImportError: If docker package not installed
            Exception: If Docker daemon not available
        """
        if self._docker is None:
            try:
                import docker

                self._docker = docker.from_env()
                logger.info("Connected to Docker daemon")
            except ImportError as e:
                msg = "Docker SDK not installed. " "Install with: pip install 'harombe[docker]'"
                raise ImportError(msg) from e
            except Exception as e:
                logger.error(f"Failed to connect to Docker daemon: {e}")
                raise

        return self._docker

    async def create_network(self, network_name: str = "harombe-network") -> None:
        """Create Docker network for capability containers.

        Args:
            network_name: Name of the Docker network

        Raises:
            Exception: If network creation fails
        """
        client = self._get_client()

        try:
            # Check if network already exists
            networks = client.networks.list(names=[network_name])
            if networks:
                logger.info(f"Docker network '{network_name}' already exists")
                return

            # Create new network
            client.networks.create(
                name=network_name,
                driver="bridge",
                check_duplicate=True,
            )
            logger.info(f"Created Docker network '{network_name}'")
        except Exception as e:
            logger.error(f"Failed to create network '{network_name}': {e}")
            raise

    async def create_container(self, config: ContainerConfig) -> str:
        """Create a new container.

        Args:
            config: Container configuration

        Returns:
            Container ID

        Raises:
            Exception: If container creation fails
        """
        client = self._get_client()

        try:
            # Check if container already exists
            if config.name in self._containers:
                logger.warning(f"Container '{config.name}' already exists")
                return self._containers[config.name].id

            # Prepare port mapping
            ports = {}
            if config.host_port:
                ports[f"{config.port}/tcp"] = config.host_port
            else:
                ports[f"{config.port}/tcp"] = config.port

            # Prepare host config (resource limits)
            host_config = {}
            if config.resource_limits:
                host_config.update(config.resource_limits.to_docker_params())

            # Prepare restart policy
            restart_policy = config.restart_policy or {"Name": "unless-stopped"}

            # Create container
            container = client.containers.create(
                image=config.image,
                name=config.name,
                ports=ports,
                environment=config.environment or {},
                volumes=config.volumes or {},
                network=config.network,
                detach=True,
                auto_remove=config.auto_remove,
                restart_policy=restart_policy,
                **host_config,
            )

            self._containers[config.name] = container
            logger.info(f"Created container '{config.name}' (id={container.short_id})")

            return container.id

        except Exception as e:
            logger.error(f"Failed to create container '{config.name}': {e}")
            raise

    async def start_container(self, name: str) -> None:
        """Start a container.

        Args:
            name: Container name

        Raises:
            ValueError: If container not found
            Exception: If start fails
        """
        if name not in self._containers:
            msg = f"Container '{name}' not found"
            raise ValueError(msg)

        try:
            container = self._containers[name]
            container.start()
            logger.info(f"Started container '{name}'")
        except Exception as e:
            logger.error(f"Failed to start container '{name}': {e}")
            raise

    async def stop_container(self, name: str, timeout: int = 10) -> None:
        """Stop a container.

        Args:
            name: Container name
            timeout: Seconds to wait before killing

        Raises:
            ValueError: If container not found
            Exception: If stop fails
        """
        if name not in self._containers:
            msg = f"Container '{name}' not found"
            raise ValueError(msg)

        try:
            container = self._containers[name]
            container.stop(timeout=timeout)
            logger.info(f"Stopped container '{name}'")
        except Exception as e:
            logger.error(f"Failed to stop container '{name}': {e}")
            raise

    async def restart_container(self, name: str, timeout: int = 10) -> None:
        """Restart a container.

        Args:
            name: Container name
            timeout: Seconds to wait before killing

        Raises:
            ValueError: If container not found
            Exception: If restart fails
        """
        if name not in self._containers:
            msg = f"Container '{name}' not found"
            raise ValueError(msg)

        try:
            container = self._containers[name]
            container.restart(timeout=timeout)
            logger.info(f"Restarted container '{name}'")
        except Exception as e:
            logger.error(f"Failed to restart container '{name}': {e}")
            raise

    async def remove_container(self, name: str, force: bool = False) -> None:
        """Remove a container.

        Args:
            name: Container name
            force: Force removal even if running

        Raises:
            ValueError: If container not found
            Exception: If removal fails
        """
        if name not in self._containers:
            msg = f"Container '{name}' not found"
            raise ValueError(msg)

        try:
            container = self._containers[name]
            container.remove(force=force)
            del self._containers[name]
            logger.info(f"Removed container '{name}'")
        except Exception as e:
            logger.error(f"Failed to remove container '{name}': {e}")
            raise

    async def get_status(self, name: str) -> ContainerStatus:
        """Get container status.

        Args:
            name: Container name

        Returns:
            Container status

        Raises:
            ValueError: If container not found
        """
        if name not in self._containers:
            msg = f"Container '{name}' not found"
            raise ValueError(msg)

        try:
            container = self._containers[name]
            container.reload()  # Refresh status
            status = container.status.lower()

            # Map Docker status to our enum
            if status in {"created", "running", "paused", "restarting", "exited", "dead"}:
                return ContainerStatus(status)

            return ContainerStatus.UNKNOWN

        except Exception as e:
            logger.error(f"Failed to get status for '{name}': {e}")
            return ContainerStatus.UNKNOWN

    async def get_logs(self, name: str, tail: int = 100) -> str:
        """Get container logs.

        Args:
            name: Container name
            tail: Number of lines to return

        Returns:
            Container logs

        Raises:
            ValueError: If container not found
        """
        if name not in self._containers:
            msg = f"Container '{name}' not found"
            raise ValueError(msg)

        try:
            container = self._containers[name]
            logs = container.logs(tail=tail).decode("utf-8")
            return logs
        except Exception as e:
            logger.error(f"Failed to get logs for '{name}': {e}")
            return f"Error retrieving logs: {e}"

    async def get_stats(self, name: str) -> dict[str, Any]:
        """Get container resource usage statistics.

        Args:
            name: Container name

        Returns:
            Stats dict with CPU, memory, network usage

        Raises:
            ValueError: If container not found
        """
        if name not in self._containers:
            msg = f"Container '{name}' not found"
            raise ValueError(msg)

        try:
            container = self._containers[name]
            stats = container.stats(stream=False)
            return stats
        except Exception as e:
            logger.error(f"Failed to get stats for '{name}': {e}")
            return {}

    async def list_containers(self) -> list[dict[str, Any]]:
        """List all managed containers.

        Returns:
            List of container info dicts
        """
        containers = []

        for name, container in self._containers.items():
            try:
                container.reload()
                containers.append(
                    {
                        "name": name,
                        "id": container.short_id,
                        "status": container.status,
                        "image": container.image.tags[0] if container.image.tags else "unknown",
                    }
                )
            except Exception as e:
                logger.warning(f"Failed to get info for '{name}': {e}")

        return containers

    async def cleanup_all(self, force: bool = False) -> None:
        """Stop and remove all managed containers.

        Args:
            force: Force removal even if running
        """
        container_names = list(self._containers.keys())

        for name in container_names:
            try:
                await self.remove_container(name, force=force)
            except Exception as e:
                logger.error(f"Failed to cleanup container '{name}': {e}")

        logger.info("Cleaned up all containers")

    def close(self) -> None:
        """Close Docker client connection."""
        if self._docker:
            self._docker.close()
            logger.info("Closed Docker client")

__init__()

Initialize Docker manager.

Source code in src/harombe/security/docker_manager.py
def __init__(self) -> None:
    """Initialize Docker manager."""
    self._docker: Any = None  # docker.DockerClient
    self._containers: dict[str, Any] = {}  # name -> container object

create_network(network_name='harombe-network') async

Create Docker network for capability containers.

Parameters:

Name Type Description Default
network_name str

Name of the Docker network

'harombe-network'

Raises:

Type Description
Exception

If network creation fails

Source code in src/harombe/security/docker_manager.py
async def create_network(self, network_name: str = "harombe-network") -> None:
    """Create Docker network for capability containers.

    Args:
        network_name: Name of the Docker network

    Raises:
        Exception: If network creation fails
    """
    client = self._get_client()

    try:
        # Check if network already exists
        networks = client.networks.list(names=[network_name])
        if networks:
            logger.info(f"Docker network '{network_name}' already exists")
            return

        # Create new network
        client.networks.create(
            name=network_name,
            driver="bridge",
            check_duplicate=True,
        )
        logger.info(f"Created Docker network '{network_name}'")
    except Exception as e:
        logger.error(f"Failed to create network '{network_name}': {e}")
        raise

create_container(config) async

Create a new container.

Parameters:

Name Type Description Default
config ContainerConfig

Container configuration

required

Returns:

Type Description
str

Container ID

Raises:

Type Description
Exception

If container creation fails

Source code in src/harombe/security/docker_manager.py
async def create_container(self, config: ContainerConfig) -> str:
    """Create a new container.

    Args:
        config: Container configuration

    Returns:
        Container ID

    Raises:
        Exception: If container creation fails
    """
    client = self._get_client()

    try:
        # Check if container already exists
        if config.name in self._containers:
            logger.warning(f"Container '{config.name}' already exists")
            return self._containers[config.name].id

        # Prepare port mapping
        ports = {}
        if config.host_port:
            ports[f"{config.port}/tcp"] = config.host_port
        else:
            ports[f"{config.port}/tcp"] = config.port

        # Prepare host config (resource limits)
        host_config = {}
        if config.resource_limits:
            host_config.update(config.resource_limits.to_docker_params())

        # Prepare restart policy
        restart_policy = config.restart_policy or {"Name": "unless-stopped"}

        # Create container
        container = client.containers.create(
            image=config.image,
            name=config.name,
            ports=ports,
            environment=config.environment or {},
            volumes=config.volumes or {},
            network=config.network,
            detach=True,
            auto_remove=config.auto_remove,
            restart_policy=restart_policy,
            **host_config,
        )

        self._containers[config.name] = container
        logger.info(f"Created container '{config.name}' (id={container.short_id})")

        return container.id

    except Exception as e:
        logger.error(f"Failed to create container '{config.name}': {e}")
        raise

start_container(name) async

Start a container.

Parameters:

Name Type Description Default
name str

Container name

required

Raises:

Type Description
ValueError

If container not found

Exception

If start fails

Source code in src/harombe/security/docker_manager.py
async def start_container(self, name: str) -> None:
    """Start a container.

    Args:
        name: Container name

    Raises:
        ValueError: If container not found
        Exception: If start fails
    """
    if name not in self._containers:
        msg = f"Container '{name}' not found"
        raise ValueError(msg)

    try:
        container = self._containers[name]
        container.start()
        logger.info(f"Started container '{name}'")
    except Exception as e:
        logger.error(f"Failed to start container '{name}': {e}")
        raise

stop_container(name, timeout=10) async

Stop a container.

Parameters:

Name Type Description Default
name str

Container name

required
timeout int

Seconds to wait before killing

10

Raises:

Type Description
ValueError

If container not found

Exception

If stop fails

Source code in src/harombe/security/docker_manager.py
async def stop_container(self, name: str, timeout: int = 10) -> None:
    """Stop a container.

    Args:
        name: Container name
        timeout: Seconds to wait before killing

    Raises:
        ValueError: If container not found
        Exception: If stop fails
    """
    if name not in self._containers:
        msg = f"Container '{name}' not found"
        raise ValueError(msg)

    try:
        container = self._containers[name]
        container.stop(timeout=timeout)
        logger.info(f"Stopped container '{name}'")
    except Exception as e:
        logger.error(f"Failed to stop container '{name}': {e}")
        raise

restart_container(name, timeout=10) async

Restart a container.

Parameters:

Name Type Description Default
name str

Container name

required
timeout int

Seconds to wait before killing

10

Raises:

Type Description
ValueError

If container not found

Exception

If restart fails

Source code in src/harombe/security/docker_manager.py
async def restart_container(self, name: str, timeout: int = 10) -> None:
    """Restart a container.

    Args:
        name: Container name
        timeout: Seconds to wait before killing

    Raises:
        ValueError: If container not found
        Exception: If restart fails
    """
    if name not in self._containers:
        msg = f"Container '{name}' not found"
        raise ValueError(msg)

    try:
        container = self._containers[name]
        container.restart(timeout=timeout)
        logger.info(f"Restarted container '{name}'")
    except Exception as e:
        logger.error(f"Failed to restart container '{name}': {e}")
        raise

remove_container(name, force=False) async

Remove a container.

Parameters:

Name Type Description Default
name str

Container name

required
force bool

Force removal even if running

False

Raises:

Type Description
ValueError

If container not found

Exception

If removal fails

Source code in src/harombe/security/docker_manager.py
async def remove_container(self, name: str, force: bool = False) -> None:
    """Remove a container.

    Args:
        name: Container name
        force: Force removal even if running

    Raises:
        ValueError: If container not found
        Exception: If removal fails
    """
    if name not in self._containers:
        msg = f"Container '{name}' not found"
        raise ValueError(msg)

    try:
        container = self._containers[name]
        container.remove(force=force)
        del self._containers[name]
        logger.info(f"Removed container '{name}'")
    except Exception as e:
        logger.error(f"Failed to remove container '{name}': {e}")
        raise

get_status(name) async

Get container status.

Parameters:

Name Type Description Default
name str

Container name

required

Returns:

Type Description
ContainerStatus

Container status

Raises:

Type Description
ValueError

If container not found

Source code in src/harombe/security/docker_manager.py
async def get_status(self, name: str) -> ContainerStatus:
    """Get container status.

    Args:
        name: Container name

    Returns:
        Container status

    Raises:
        ValueError: If container not found
    """
    if name not in self._containers:
        msg = f"Container '{name}' not found"
        raise ValueError(msg)

    try:
        container = self._containers[name]
        container.reload()  # Refresh status
        status = container.status.lower()

        # Map Docker status to our enum
        if status in {"created", "running", "paused", "restarting", "exited", "dead"}:
            return ContainerStatus(status)

        return ContainerStatus.UNKNOWN

    except Exception as e:
        logger.error(f"Failed to get status for '{name}': {e}")
        return ContainerStatus.UNKNOWN

get_logs(name, tail=100) async

Get container logs.

Parameters:

Name Type Description Default
name str

Container name

required
tail int

Number of lines to return

100

Returns:

Type Description
str

Container logs

Raises:

Type Description
ValueError

If container not found

Source code in src/harombe/security/docker_manager.py
async def get_logs(self, name: str, tail: int = 100) -> str:
    """Get container logs.

    Args:
        name: Container name
        tail: Number of lines to return

    Returns:
        Container logs

    Raises:
        ValueError: If container not found
    """
    if name not in self._containers:
        msg = f"Container '{name}' not found"
        raise ValueError(msg)

    try:
        container = self._containers[name]
        logs = container.logs(tail=tail).decode("utf-8")
        return logs
    except Exception as e:
        logger.error(f"Failed to get logs for '{name}': {e}")
        return f"Error retrieving logs: {e}"

get_stats(name) async

Get container resource usage statistics.

Parameters:

Name Type Description Default
name str

Container name

required

Returns:

Type Description
dict[str, Any]

Stats dict with CPU, memory, network usage

Raises:

Type Description
ValueError

If container not found

Source code in src/harombe/security/docker_manager.py
async def get_stats(self, name: str) -> dict[str, Any]:
    """Get container resource usage statistics.

    Args:
        name: Container name

    Returns:
        Stats dict with CPU, memory, network usage

    Raises:
        ValueError: If container not found
    """
    if name not in self._containers:
        msg = f"Container '{name}' not found"
        raise ValueError(msg)

    try:
        container = self._containers[name]
        stats = container.stats(stream=False)
        return stats
    except Exception as e:
        logger.error(f"Failed to get stats for '{name}': {e}")
        return {}

list_containers() async

List all managed containers.

Returns:

Type Description
list[dict[str, Any]]

List of container info dicts

Source code in src/harombe/security/docker_manager.py
async def list_containers(self) -> list[dict[str, Any]]:
    """List all managed containers.

    Returns:
        List of container info dicts
    """
    containers = []

    for name, container in self._containers.items():
        try:
            container.reload()
            containers.append(
                {
                    "name": name,
                    "id": container.short_id,
                    "status": container.status,
                    "image": container.image.tags[0] if container.image.tags else "unknown",
                }
            )
        except Exception as e:
            logger.warning(f"Failed to get info for '{name}': {e}")

    return containers

cleanup_all(force=False) async

Stop and remove all managed containers.

Parameters:

Name Type Description Default
force bool

Force removal even if running

False
Source code in src/harombe/security/docker_manager.py
async def cleanup_all(self, force: bool = False) -> None:
    """Stop and remove all managed containers.

    Args:
        force: Force removal even if running
    """
    container_names = list(self._containers.keys())

    for name in container_names:
        try:
            await self.remove_container(name, force=force)
        except Exception as e:
            logger.error(f"Failed to cleanup container '{name}': {e}")

    logger.info("Cleaned up all containers")

close()

Close Docker client connection.

Source code in src/harombe/security/docker_manager.py
def close(self) -> None:
    """Close Docker client connection."""
    if self._docker:
        self._docker.close()
        logger.info("Closed Docker client")

MCPGateway

MCP Gateway server for routing and security enforcement.

Source code in src/harombe/security/gateway.py
class MCPGateway:
    """MCP Gateway server for routing and security enforcement."""

    def __init__(
        self,
        host: str = "127.0.0.1",
        port: int = 8100,
        version: str = "0.1.0",
        audit_db_path: str = "~/.harombe/audit.db",
        enable_audit_logging: bool = True,
        enable_hitl: bool = False,
        hitl_prompt_callback: Any | None = None,
    ):
        """Initialize MCP Gateway.

        Args:
            host: Host to bind to
            port: Port to listen on
            version: Gateway version
            audit_db_path: Path to audit database
            enable_audit_logging: Enable audit logging
            enable_hitl: Enable Human-in-the-Loop approval gates
            hitl_prompt_callback: Optional callback for prompting users for approval
        """
        self.host = host
        self.port = port
        self.version = version
        self.app = FastAPI(
            title="Harombe MCP Gateway",
            description="Central security enforcement point for AI agent tool execution",
            version=version,
        )
        self.client_pool = MCPClientPool()
        self.start_time = time.time()

        # Audit logging
        self.enable_audit_logging = enable_audit_logging
        self.audit_logger: AuditLogger | None = None
        if enable_audit_logging:
            self.audit_logger = AuditLogger(db_path=audit_db_path)

        # HITL gates
        self.enable_hitl = enable_hitl
        self.hitl_gate: HITLGate | None = None
        self.hitl_prompt_callback = hitl_prompt_callback
        if enable_hitl:
            self.hitl_gate = HITLGate()

        # Register routes
        self._setup_routes()

    def _setup_routes(self) -> None:
        """Set up FastAPI routes."""

        @self.app.post("/mcp")
        async def handle_mcp_request(request: Request) -> JSONResponse:
            """Handle MCP JSON-RPC requests.

            Args:
                request: FastAPI request

            Returns:
                JSON-RPC response
            """
            start_time = time.time()
            correlation_id: str | None = None

            try:
                # Parse request
                body = await request.json()
                mcp_request = MCPRequest(**body)

                logger.info(f"Received MCP request: {mcp_request.method} (id={mcp_request.id})")

                # Extract tool name
                tool_params = mcp_request.get_tool_params()
                if tool_params is None:
                    error_response = create_error_response(
                        request_id=mcp_request.id,
                        code=ErrorCode.INVALID_PARAMS,
                        message="Invalid method or parameters",
                        details=f"Method '{mcp_request.method}' is not supported",
                    )

                    # Log error
                    if self.audit_logger:
                        correlation_id = self.audit_logger.start_request_sync(
                            actor="agent",
                            action=mcp_request.method,
                            metadata={"request_id": mcp_request.id},
                        )
                        self.audit_logger.end_request_sync(
                            correlation_id=correlation_id,
                            status="error",
                            error_message="Invalid method or parameters",
                        )

                    return JSONResponse(content=error_response.model_dump(mode="json"))

                tool_name = tool_params.name

                # Start audit logging
                if self.audit_logger:
                    correlation_id = self.audit_logger.start_request_sync(
                        actor="agent",
                        tool_name=tool_name,
                        action=mcp_request.method,
                        metadata={
                            "request_id": mcp_request.id,
                            "tool_params": tool_params.model_dump(mode="json"),
                        },
                    )

                # Route to container
                if tool_name not in TOOL_ROUTES:
                    error_response = create_error_response(
                        request_id=mcp_request.id,
                        code=ErrorCode.METHOD_NOT_FOUND,
                        message=f"Tool '{tool_name}' not found",
                        details=f"No container registered for tool '{tool_name}'",
                    )

                    # Log error
                    if self.audit_logger and correlation_id:
                        self.audit_logger.end_request_sync(
                            correlation_id=correlation_id,
                            status="error",
                            error_message=f"Tool '{tool_name}' not found",
                            duration_ms=int((time.time() - start_time) * 1000),
                        )

                    return JSONResponse(content=error_response.model_dump(mode="json"))

                container = TOOL_ROUTES[tool_name]
                logger.debug(f"Routing {tool_name} to {container}")

                # Check HITL approval if enabled
                if self.hitl_gate:
                    operation = Operation(
                        tool_name=tool_name,
                        params=tool_params.arguments or {},
                        correlation_id=correlation_id or mcp_request.id,
                        session_id=getattr(request.state, "session_id", None),
                        metadata={
                            "request_id": mcp_request.id,
                            "container": container,
                        },
                    )

                    approval_decision = await self.hitl_gate.check_approval(
                        operation=operation,
                        user="agent",
                        prompt_callback=self.hitl_prompt_callback,
                    )

                    # Log HITL decision
                    if self.audit_logger and correlation_id:
                        self.audit_logger.log_security_decision(
                            correlation_id=correlation_id,
                            decision="allow"
                            if approval_decision.decision == ApprovalStatus.APPROVED
                            or approval_decision.decision == ApprovalStatus.AUTO_APPROVED
                            else "deny",
                            reason=approval_decision.reason
                            or f"HITL decision: {approval_decision.decision}",
                            metadata={
                                "approval_status": approval_decision.decision,
                                "approval_user": approval_decision.user,
                                "approval_timestamp": approval_decision.timestamp.isoformat(),
                            },
                        )

                    # Deny if not approved
                    if approval_decision.decision not in (
                        ApprovalStatus.APPROVED,
                        ApprovalStatus.AUTO_APPROVED,
                    ):
                        error_response = create_error_response(
                            request_id=mcp_request.id,
                            code=ErrorCode.AUTHORIZATION_DENIED,
                            message=f"Operation denied by HITL gate: {approval_decision.decision}",
                            details=approval_decision.reason or "No reason provided",
                        )

                        # Log denial
                        if self.audit_logger and correlation_id:
                            self.audit_logger.end_request_sync(
                                correlation_id=correlation_id,
                                status="denied",
                                error_message=f"HITL denied: {approval_decision.reason}",
                                duration_ms=int((time.time() - start_time) * 1000),
                            )

                        return JSONResponse(content=error_response.model_dump(mode="json"))

                # Forward request to container
                response = await self.client_pool.send_request(container, mcp_request)

                # Log tool call
                duration_ms = int((time.time() - start_time) * 1000)
                if self.audit_logger and correlation_id:
                    # Determine status
                    is_error = hasattr(response, "error") and response.error is not None
                    status = "error" if is_error else "success"

                    # Log completion
                    self.audit_logger.end_request_sync(
                        correlation_id=correlation_id,
                        status=status,
                        duration_ms=duration_ms,
                        error_message=str(response.error) if is_error else None,
                    )

                    # Log tool call details
                    result_dict = None
                    if hasattr(response, "result") and response.result is not None:
                        result_dict = response.result.model_dump(mode="json")

                    self.audit_logger.log_tool_call(
                        correlation_id=correlation_id,
                        tool_name=tool_name,
                        method=mcp_request.method,
                        parameters=tool_params.model_dump(mode="json"),
                        result=result_dict,
                        error=str(response.error) if is_error else None,
                        duration_ms=duration_ms,
                        container_id=container,
                    )

                return JSONResponse(content=response.model_dump(mode="json"))

            except ValueError as e:
                # Invalid JSON-RPC format
                duration_ms = int((time.time() - start_time) * 1000)

                if self.audit_logger:
                    if correlation_id is None:
                        correlation_id = self.audit_logger.start_request_sync(
                            actor="agent",
                            action="invalid_request",
                        )
                    self.audit_logger.end_request_sync(
                        correlation_id=correlation_id,
                        status="error",
                        error_message=f"Invalid request format: {e!s}",
                        duration_ms=duration_ms,
                    )

                return JSONResponse(
                    content=create_error_response(
                        request_id="unknown",
                        code=ErrorCode.INVALID_REQUEST,
                        message="Invalid request format",
                        details=str(e),
                    ).model_dump(mode="json"),
                    status_code=400,
                )

            except Exception as e:
                logger.exception("Unexpected error handling MCP request")
                duration_ms = int((time.time() - start_time) * 1000)

                if self.audit_logger and correlation_id:
                    self.audit_logger.end_request_sync(
                        correlation_id=correlation_id,
                        status="error",
                        error_message=f"Internal gateway error: {e!s}",
                        duration_ms=duration_ms,
                    )

                return JSONResponse(
                    content=create_error_response(
                        request_id="unknown",
                        code=ErrorCode.INTERNAL_ERROR,
                        message="Internal gateway error",
                        details=str(e),
                    ).model_dump(mode="json"),
                    status_code=500,
                )

        @self.app.get("/health")
        async def health_check() -> HealthStatus:
            """Gateway health check.

            Returns:
                Health status with container statuses
            """
            uptime = int(time.time() - self.start_time)
            container_statuses = self.client_pool.get_health_status()

            return HealthStatus(
                status="healthy",
                version=self.version,
                uptime=uptime,
                containers=container_statuses if container_statuses else None,
            )

        @self.app.get("/ready")
        async def readiness_check() -> ReadinessStatus:
            """Gateway readiness check.

            Returns:
                Readiness status (all containers healthy)
            """
            container_statuses = self.client_pool.get_health_status()
            healthy_count = sum(1 for status in container_statuses.values() if status == "healthy")
            total_count = len(container_statuses)

            return ReadinessStatus(
                ready=healthy_count == total_count and total_count > 0,
                containers_healthy=healthy_count,
                containers_total=total_count,
            )

    async def startup(self) -> None:
        """Gateway startup tasks."""
        logger.info(f"MCP Gateway starting on {self.host}:{self.port}")
        logger.info(f"Version: {self.version}")
        logger.info(f"Registered tools: {len(TOOL_ROUTES)}")

        # Start audit logger
        if self.audit_logger:
            await self.audit_logger.start()
            logger.info("Audit logging enabled")

    async def shutdown(self) -> None:
        """Gateway shutdown tasks."""
        logger.info("MCP Gateway shutting down")

        # Stop audit logger
        if self.audit_logger:
            await self.audit_logger.stop()
            logger.info("Audit logger stopped")

        await self.client_pool.close_all()

__init__(host='127.0.0.1', port=8100, version='0.1.0', audit_db_path='~/.harombe/audit.db', enable_audit_logging=True, enable_hitl=False, hitl_prompt_callback=None)

Initialize MCP Gateway.

Parameters:

Name Type Description Default
host str

Host to bind to

'127.0.0.1'
port int

Port to listen on

8100
version str

Gateway version

'0.1.0'
audit_db_path str

Path to audit database

'~/.harombe/audit.db'
enable_audit_logging bool

Enable audit logging

True
enable_hitl bool

Enable Human-in-the-Loop approval gates

False
hitl_prompt_callback Any | None

Optional callback for prompting users for approval

None
Source code in src/harombe/security/gateway.py
def __init__(
    self,
    host: str = "127.0.0.1",
    port: int = 8100,
    version: str = "0.1.0",
    audit_db_path: str = "~/.harombe/audit.db",
    enable_audit_logging: bool = True,
    enable_hitl: bool = False,
    hitl_prompt_callback: Any | None = None,
):
    """Initialize MCP Gateway.

    Args:
        host: Host to bind to
        port: Port to listen on
        version: Gateway version
        audit_db_path: Path to audit database
        enable_audit_logging: Enable audit logging
        enable_hitl: Enable Human-in-the-Loop approval gates
        hitl_prompt_callback: Optional callback for prompting users for approval
    """
    self.host = host
    self.port = port
    self.version = version
    self.app = FastAPI(
        title="Harombe MCP Gateway",
        description="Central security enforcement point for AI agent tool execution",
        version=version,
    )
    self.client_pool = MCPClientPool()
    self.start_time = time.time()

    # Audit logging
    self.enable_audit_logging = enable_audit_logging
    self.audit_logger: AuditLogger | None = None
    if enable_audit_logging:
        self.audit_logger = AuditLogger(db_path=audit_db_path)

    # HITL gates
    self.enable_hitl = enable_hitl
    self.hitl_gate: HITLGate | None = None
    self.hitl_prompt_callback = hitl_prompt_callback
    if enable_hitl:
        self.hitl_gate = HITLGate()

    # Register routes
    self._setup_routes()

startup() async

Gateway startup tasks.

Source code in src/harombe/security/gateway.py
async def startup(self) -> None:
    """Gateway startup tasks."""
    logger.info(f"MCP Gateway starting on {self.host}:{self.port}")
    logger.info(f"Version: {self.version}")
    logger.info(f"Registered tools: {len(TOOL_ROUTES)}")

    # Start audit logger
    if self.audit_logger:
        await self.audit_logger.start()
        logger.info("Audit logging enabled")

shutdown() async

Gateway shutdown tasks.

Source code in src/harombe/security/gateway.py
async def shutdown(self) -> None:
    """Gateway shutdown tasks."""
    logger.info("MCP Gateway shutting down")

    # Stop audit logger
    if self.audit_logger:
        await self.audit_logger.stop()
        logger.info("Audit logger stopped")

    await self.client_pool.close_all()

ApprovalDecision dataclass

Result of approval request.

Source code in src/harombe/security/hitl/core.py
@dataclass
class ApprovalDecision:
    """Result of approval request."""

    decision: ApprovalStatus
    user: str | None = None
    timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
    reason: str | None = None
    timeout_seconds: int | None = None
    approval_id: str | None = None

ApprovalStatus

Bases: StrEnum

Status of approval request.

Source code in src/harombe/security/hitl/core.py
class ApprovalStatus(StrEnum):
    """Status of approval request."""

    PENDING = "pending"  # Waiting for user decision
    APPROVED = "approved"  # User approved
    DENIED = "denied"  # User denied
    TIMEOUT = "timeout"  # Request timed out
    AUTO_APPROVED = "auto_approved"  # Auto-approved (low risk)

HITLGate

Human-in-the-Loop gate for operation approval.

Source code in src/harombe/security/hitl/core.py
class HITLGate:
    """Human-in-the-Loop gate for operation approval."""

    def __init__(
        self,
        classifier: RiskClassifier | None = None,
        auto_approve_low_risk: bool = True,
        default_timeout: int = 60,
    ):
        """
        Initialize HITL gate.

        Args:
            classifier: Risk classifier for operations
            auto_approve_low_risk: Auto-approve low-risk operations
            default_timeout: Default timeout in seconds
        """
        self.classifier = classifier or RiskClassifier()
        self.auto_approve_low_risk = auto_approve_low_risk
        self.default_timeout = default_timeout
        self.pending_approvals: dict[str, PendingApproval] = {}

    async def check_approval(
        self,
        operation: Operation,
        user: str | None = None,
        prompt_callback: Callable | None = None,
    ) -> ApprovalDecision:
        """
        Check if operation requires approval and get decision.

        Args:
            operation: The operation to check
            user: User requesting the operation
            prompt_callback: Optional callback to prompt user

        Returns:
            Approval decision
        """
        # Classify risk
        risk_level = self.classifier.classify(operation)

        # Auto-approve low-risk operations if configured
        if self.auto_approve_low_risk and risk_level == RiskLevel.LOW:
            return ApprovalDecision(
                decision=ApprovalStatus.AUTO_APPROVED,
                user=user,
                timestamp=datetime.now(UTC),
                reason="Low risk operation",
            )

        # Check if approval required
        if not self.classifier.requires_approval(operation):
            return ApprovalDecision(
                decision=ApprovalStatus.AUTO_APPROVED,
                user=user,
                timestamp=datetime.now(UTC),
                reason="Approval not required by policy",
            )

        # Get timeout for operation
        timeout = self.classifier.get_timeout(operation)

        # Create pending approval
        approval_id = str(uuid4())
        pending = PendingApproval(
            approval_id=approval_id,
            operation=operation,
            risk_level=risk_level,
            timeout=timeout,
        )

        self.pending_approvals[approval_id] = pending

        # Prompt user if callback provided
        prompt_task = None
        if prompt_callback:
            prompt_task = asyncio.create_task(self._prompt_user(pending, prompt_callback))

        # Wait for decision
        decision = await pending.wait_for_decision()

        # Cancel prompt task if still running
        if prompt_task and not prompt_task.done():
            prompt_task.cancel()

        # Clean up
        if approval_id in self.pending_approvals:
            del self.pending_approvals[approval_id]

        return decision

    async def _prompt_user(self, pending: PendingApproval, prompt_callback: Callable) -> None:
        """Prompt user for approval."""
        try:
            decision = await prompt_callback(pending.operation, pending.risk_level, pending.timeout)
            pending.set_decision(decision)
        except Exception as e:
            # Error prompting: auto-deny
            pending.set_decision(
                ApprovalDecision(
                    decision=ApprovalStatus.DENIED,
                    timestamp=datetime.now(UTC),
                    reason=f"Error prompting user: {e}",
                    approval_id=pending.approval_id,
                )
            )

    def approve(
        self,
        approval_id: str,
        user: str,
        reason: str | None = None,
    ) -> bool:
        """
        Approve a pending operation.

        Args:
            approval_id: Approval request ID
            user: User approving the operation
            reason: Optional reason for approval

        Returns:
            True if approval was successful
        """
        if approval_id not in self.pending_approvals:
            return False

        pending = self.pending_approvals[approval_id]

        if pending.is_expired():
            # Already expired
            return False

        decision = ApprovalDecision(
            decision=ApprovalStatus.APPROVED,
            user=user,
            timestamp=datetime.now(UTC),
            reason=reason,
            approval_id=approval_id,
        )

        pending.set_decision(decision)
        return True

    def deny(
        self,
        approval_id: str,
        user: str,
        reason: str | None = None,
    ) -> bool:
        """
        Deny a pending operation.

        Args:
            approval_id: Approval request ID
            user: User denying the operation
            reason: Optional reason for denial

        Returns:
            True if denial was successful
        """
        if approval_id not in self.pending_approvals:
            return False

        pending = self.pending_approvals[approval_id]

        decision = ApprovalDecision(
            decision=ApprovalStatus.DENIED,
            user=user,
            timestamp=datetime.now(UTC),
            reason=reason,
            approval_id=approval_id,
        )

        pending.set_decision(decision)
        return True

    def get_pending(self, approval_id: str) -> PendingApproval | None:
        """Get pending approval by ID."""
        return self.pending_approvals.get(approval_id)

    def list_pending(self) -> list[PendingApproval]:
        """List all pending approvals."""
        # Clean up expired approvals
        time.time()
        expired = [aid for aid, pending in self.pending_approvals.items() if pending.is_expired()]

        for aid in expired:
            del self.pending_approvals[aid]

        return list(self.pending_approvals.values())

__init__(classifier=None, auto_approve_low_risk=True, default_timeout=60)

Initialize HITL gate.

Parameters:

Name Type Description Default
classifier RiskClassifier | None

Risk classifier for operations

None
auto_approve_low_risk bool

Auto-approve low-risk operations

True
default_timeout int

Default timeout in seconds

60
Source code in src/harombe/security/hitl/core.py
def __init__(
    self,
    classifier: RiskClassifier | None = None,
    auto_approve_low_risk: bool = True,
    default_timeout: int = 60,
):
    """
    Initialize HITL gate.

    Args:
        classifier: Risk classifier for operations
        auto_approve_low_risk: Auto-approve low-risk operations
        default_timeout: Default timeout in seconds
    """
    self.classifier = classifier or RiskClassifier()
    self.auto_approve_low_risk = auto_approve_low_risk
    self.default_timeout = default_timeout
    self.pending_approvals: dict[str, PendingApproval] = {}

check_approval(operation, user=None, prompt_callback=None) async

Check if operation requires approval and get decision.

Parameters:

Name Type Description Default
operation Operation

The operation to check

required
user str | None

User requesting the operation

None
prompt_callback Callable | None

Optional callback to prompt user

None

Returns:

Type Description
ApprovalDecision

Approval decision

Source code in src/harombe/security/hitl/core.py
async def check_approval(
    self,
    operation: Operation,
    user: str | None = None,
    prompt_callback: Callable | None = None,
) -> ApprovalDecision:
    """
    Check if operation requires approval and get decision.

    Args:
        operation: The operation to check
        user: User requesting the operation
        prompt_callback: Optional callback to prompt user

    Returns:
        Approval decision
    """
    # Classify risk
    risk_level = self.classifier.classify(operation)

    # Auto-approve low-risk operations if configured
    if self.auto_approve_low_risk and risk_level == RiskLevel.LOW:
        return ApprovalDecision(
            decision=ApprovalStatus.AUTO_APPROVED,
            user=user,
            timestamp=datetime.now(UTC),
            reason="Low risk operation",
        )

    # Check if approval required
    if not self.classifier.requires_approval(operation):
        return ApprovalDecision(
            decision=ApprovalStatus.AUTO_APPROVED,
            user=user,
            timestamp=datetime.now(UTC),
            reason="Approval not required by policy",
        )

    # Get timeout for operation
    timeout = self.classifier.get_timeout(operation)

    # Create pending approval
    approval_id = str(uuid4())
    pending = PendingApproval(
        approval_id=approval_id,
        operation=operation,
        risk_level=risk_level,
        timeout=timeout,
    )

    self.pending_approvals[approval_id] = pending

    # Prompt user if callback provided
    prompt_task = None
    if prompt_callback:
        prompt_task = asyncio.create_task(self._prompt_user(pending, prompt_callback))

    # Wait for decision
    decision = await pending.wait_for_decision()

    # Cancel prompt task if still running
    if prompt_task and not prompt_task.done():
        prompt_task.cancel()

    # Clean up
    if approval_id in self.pending_approvals:
        del self.pending_approvals[approval_id]

    return decision

approve(approval_id, user, reason=None)

Approve a pending operation.

Parameters:

Name Type Description Default
approval_id str

Approval request ID

required
user str

User approving the operation

required
reason str | None

Optional reason for approval

None

Returns:

Type Description
bool

True if approval was successful

Source code in src/harombe/security/hitl/core.py
def approve(
    self,
    approval_id: str,
    user: str,
    reason: str | None = None,
) -> bool:
    """
    Approve a pending operation.

    Args:
        approval_id: Approval request ID
        user: User approving the operation
        reason: Optional reason for approval

    Returns:
        True if approval was successful
    """
    if approval_id not in self.pending_approvals:
        return False

    pending = self.pending_approvals[approval_id]

    if pending.is_expired():
        # Already expired
        return False

    decision = ApprovalDecision(
        decision=ApprovalStatus.APPROVED,
        user=user,
        timestamp=datetime.now(UTC),
        reason=reason,
        approval_id=approval_id,
    )

    pending.set_decision(decision)
    return True

deny(approval_id, user, reason=None)

Deny a pending operation.

Parameters:

Name Type Description Default
approval_id str

Approval request ID

required
user str

User denying the operation

required
reason str | None

Optional reason for denial

None

Returns:

Type Description
bool

True if denial was successful

Source code in src/harombe/security/hitl/core.py
def deny(
    self,
    approval_id: str,
    user: str,
    reason: str | None = None,
) -> bool:
    """
    Deny a pending operation.

    Args:
        approval_id: Approval request ID
        user: User denying the operation
        reason: Optional reason for denial

    Returns:
        True if denial was successful
    """
    if approval_id not in self.pending_approvals:
        return False

    pending = self.pending_approvals[approval_id]

    decision = ApprovalDecision(
        decision=ApprovalStatus.DENIED,
        user=user,
        timestamp=datetime.now(UTC),
        reason=reason,
        approval_id=approval_id,
    )

    pending.set_decision(decision)
    return True

get_pending(approval_id)

Get pending approval by ID.

Source code in src/harombe/security/hitl/core.py
def get_pending(self, approval_id: str) -> PendingApproval | None:
    """Get pending approval by ID."""
    return self.pending_approvals.get(approval_id)

list_pending()

List all pending approvals.

Source code in src/harombe/security/hitl/core.py
def list_pending(self) -> list[PendingApproval]:
    """List all pending approvals."""
    # Clean up expired approvals
    time.time()
    expired = [aid for aid, pending in self.pending_approvals.items() if pending.is_expired()]

    for aid in expired:
        del self.pending_approvals[aid]

    return list(self.pending_approvals.values())

HITLRule dataclass

Rule for determining if approval is required.

Source code in src/harombe/security/hitl/core.py
@dataclass
class HITLRule:
    """Rule for determining if approval is required."""

    tools: list[str]  # Tool names this rule applies to
    risk: RiskLevel
    require_approval: bool = True
    timeout: int = 60  # seconds
    conditions: list[dict[str, Any]] | None = None
    description: str | None = None

Operation dataclass

Represents an operation requiring approval.

Source code in src/harombe/security/hitl/core.py
@dataclass
class Operation:
    """Represents an operation requiring approval."""

    tool_name: str
    params: dict[str, Any]
    correlation_id: str
    session_id: str | None = None
    metadata: dict[str, Any] = field(default_factory=dict)

PendingApproval

Represents a pending approval request.

Source code in src/harombe/security/hitl/core.py
class PendingApproval:
    """Represents a pending approval request."""

    def __init__(
        self,
        approval_id: str,
        operation: Operation,
        risk_level: RiskLevel,
        timeout: int,
    ):
        """
        Initialize pending approval.

        Args:
            approval_id: Unique approval identifier
            operation: The operation requiring approval
            risk_level: Risk level of the operation
            timeout: Timeout in seconds
        """
        self.approval_id = approval_id
        self.operation = operation
        self.risk_level = risk_level
        self.timeout = timeout
        self.created_at = time.time()
        self.status = ApprovalStatus.PENDING
        self.decision: ApprovalDecision | None = None
        self._future: asyncio.Future | None = None

    def is_expired(self) -> bool:
        """Check if approval request has expired."""
        return time.time() - self.created_at > self.timeout

    async def wait_for_decision(self) -> ApprovalDecision:
        """Wait for user decision or timeout."""
        if self._future is None:
            self._future = asyncio.Future()

        try:
            # Wait for decision or timeout
            return await asyncio.wait_for(self._future, timeout=self.timeout)
        except TimeoutError:
            # Timeout: auto-deny
            decision = ApprovalDecision(
                decision=ApprovalStatus.TIMEOUT,
                timestamp=datetime.now(UTC),
                timeout_seconds=self.timeout,
                approval_id=self.approval_id,
            )
            self.status = ApprovalStatus.TIMEOUT
            self.decision = decision
            return decision

    def set_decision(self, decision: ApprovalDecision) -> None:
        """Set the approval decision."""
        self.decision = decision
        self.status = decision.decision

        if self._future and not self._future.done():
            self._future.set_result(decision)

__init__(approval_id, operation, risk_level, timeout)

Initialize pending approval.

Parameters:

Name Type Description Default
approval_id str

Unique approval identifier

required
operation Operation

The operation requiring approval

required
risk_level RiskLevel

Risk level of the operation

required
timeout int

Timeout in seconds

required
Source code in src/harombe/security/hitl/core.py
def __init__(
    self,
    approval_id: str,
    operation: Operation,
    risk_level: RiskLevel,
    timeout: int,
):
    """
    Initialize pending approval.

    Args:
        approval_id: Unique approval identifier
        operation: The operation requiring approval
        risk_level: Risk level of the operation
        timeout: Timeout in seconds
    """
    self.approval_id = approval_id
    self.operation = operation
    self.risk_level = risk_level
    self.timeout = timeout
    self.created_at = time.time()
    self.status = ApprovalStatus.PENDING
    self.decision: ApprovalDecision | None = None
    self._future: asyncio.Future | None = None

is_expired()

Check if approval request has expired.

Source code in src/harombe/security/hitl/core.py
def is_expired(self) -> bool:
    """Check if approval request has expired."""
    return time.time() - self.created_at > self.timeout

wait_for_decision() async

Wait for user decision or timeout.

Source code in src/harombe/security/hitl/core.py
async def wait_for_decision(self) -> ApprovalDecision:
    """Wait for user decision or timeout."""
    if self._future is None:
        self._future = asyncio.Future()

    try:
        # Wait for decision or timeout
        return await asyncio.wait_for(self._future, timeout=self.timeout)
    except TimeoutError:
        # Timeout: auto-deny
        decision = ApprovalDecision(
            decision=ApprovalStatus.TIMEOUT,
            timestamp=datetime.now(UTC),
            timeout_seconds=self.timeout,
            approval_id=self.approval_id,
        )
        self.status = ApprovalStatus.TIMEOUT
        self.decision = decision
        return decision

set_decision(decision)

Set the approval decision.

Source code in src/harombe/security/hitl/core.py
def set_decision(self, decision: ApprovalDecision) -> None:
    """Set the approval decision."""
    self.decision = decision
    self.status = decision.decision

    if self._future and not self._future.done():
        self._future.set_result(decision)

RiskClassifier

Classifies operations by risk level.

Source code in src/harombe/security/hitl/core.py
class RiskClassifier:
    """Classifies operations by risk level."""

    def __init__(self, rules: list[HITLRule] | None = None):
        """
        Initialize risk classifier.

        Args:
            rules: List of HITL rules for classification
        """
        self.rules = rules or self._default_rules()

    def _default_rules(self) -> list[HITLRule]:
        """Default risk classification rules."""
        return [
            # Critical operations
            HITLRule(
                tools=["delete_database", "drop_table", "format_disk"],
                risk=RiskLevel.CRITICAL,
                description="Irreversible data loss operations",
            ),
            # High risk operations
            HITLRule(
                tools=["send_email", "post_message", "delete_file", "execute_sql"],
                risk=RiskLevel.HIGH,
                timeout=60,
                description="Operations that are hard to undo",
            ),
            # Medium risk operations
            HITLRule(
                tools=["write_file", "modify_file", "create_resource"],
                risk=RiskLevel.MEDIUM,
                timeout=120,
                description="Modifications with possible undo",
            ),
            # Low risk operations (read-only)
            HITLRule(
                tools=["read_file", "list_files", "web_search", "get_data"],
                risk=RiskLevel.LOW,
                require_approval=False,
                description="Read-only operations",
            ),
        ]

    def classify(self, operation: Operation) -> RiskLevel:
        """
        Classify operation risk level.

        Args:
            operation: The operation to classify

        Returns:
            Risk level for the operation
        """
        # Check each rule
        for rule in self.rules:
            if operation.tool_name in rule.tools:
                # Check additional conditions if present
                if rule.conditions:
                    if self._check_conditions(operation, rule.conditions):
                        return rule.risk
                else:
                    return rule.risk

        # Default: medium risk for unknown operations
        return RiskLevel.MEDIUM

    def _check_conditions(self, operation: Operation, conditions: list[dict[str, Any]]) -> bool:
        """Check if operation meets all conditions."""
        for condition in conditions:
            param = condition.get("param")
            if param not in operation.params:
                return False

            value = operation.params[param]

            # Check different condition types
            if "equals" in condition and value != condition["equals"]:
                return False

            if "matches" in condition:
                import re

                if not re.match(condition["matches"], str(value)):
                    return False

            if "in" in condition and value not in condition["in"]:
                return False

        return True

    def requires_approval(self, operation: Operation) -> bool:
        """Check if operation requires approval."""
        for rule in self.rules:
            if operation.tool_name in rule.tools:
                if rule.conditions:
                    if self._check_conditions(operation, rule.conditions):
                        return rule.require_approval
                else:
                    return rule.require_approval

        # Default: require approval for unknown operations
        return True

    def get_timeout(self, operation: Operation) -> int:
        """Get timeout for operation."""
        for rule in self.rules:
            if operation.tool_name in rule.tools:
                if rule.conditions:
                    if self._check_conditions(operation, rule.conditions):
                        return rule.timeout
                else:
                    return rule.timeout

        # Default timeout
        return 60

__init__(rules=None)

Initialize risk classifier.

Parameters:

Name Type Description Default
rules list[HITLRule] | None

List of HITL rules for classification

None
Source code in src/harombe/security/hitl/core.py
def __init__(self, rules: list[HITLRule] | None = None):
    """
    Initialize risk classifier.

    Args:
        rules: List of HITL rules for classification
    """
    self.rules = rules or self._default_rules()

classify(operation)

Classify operation risk level.

Parameters:

Name Type Description Default
operation Operation

The operation to classify

required

Returns:

Type Description
RiskLevel

Risk level for the operation

Source code in src/harombe/security/hitl/core.py
def classify(self, operation: Operation) -> RiskLevel:
    """
    Classify operation risk level.

    Args:
        operation: The operation to classify

    Returns:
        Risk level for the operation
    """
    # Check each rule
    for rule in self.rules:
        if operation.tool_name in rule.tools:
            # Check additional conditions if present
            if rule.conditions:
                if self._check_conditions(operation, rule.conditions):
                    return rule.risk
            else:
                return rule.risk

    # Default: medium risk for unknown operations
    return RiskLevel.MEDIUM

requires_approval(operation)

Check if operation requires approval.

Source code in src/harombe/security/hitl/core.py
def requires_approval(self, operation: Operation) -> bool:
    """Check if operation requires approval."""
    for rule in self.rules:
        if operation.tool_name in rule.tools:
            if rule.conditions:
                if self._check_conditions(operation, rule.conditions):
                    return rule.require_approval
            else:
                return rule.require_approval

    # Default: require approval for unknown operations
    return True

get_timeout(operation)

Get timeout for operation.

Source code in src/harombe/security/hitl/core.py
def get_timeout(self, operation: Operation) -> int:
    """Get timeout for operation."""
    for rule in self.rules:
        if operation.tool_name in rule.tools:
            if rule.conditions:
                if self._check_conditions(operation, rule.conditions):
                    return rule.timeout
            else:
                return rule.timeout

    # Default timeout
    return 60

RiskLevel

Bases: StrEnum

Risk classification for operations.

Source code in src/harombe/security/hitl/core.py
class RiskLevel(StrEnum):
    """Risk classification for operations."""

    LOW = "low"  # Read-only operations, safe actions
    MEDIUM = "medium"  # Modifications with easy undo
    HIGH = "high"  # Destructive operations, hard to undo
    CRITICAL = "critical"  # Irreversible operations, data loss

APIApprovalPrompt

API-based approval prompts (for web UI, etc.).

Source code in src/harombe/security/hitl_prompt.py
class APIApprovalPrompt:
    """API-based approval prompts (for web UI, etc.)."""

    def __init__(self):
        """Initialize API approval prompt."""
        self.pending_prompts = {}

    def create_prompt(
        self,
        approval_id: str,
        operation: Operation,
        risk_level: RiskLevel,
        timeout: int,
    ) -> dict:
        """
        Create API approval prompt data.

        Args:
            approval_id: Unique approval identifier
            operation: The operation requiring approval
            risk_level: Risk level of the operation
            timeout: Timeout in seconds

        Returns:
            Dict with prompt data for API clients
        """
        return {
            "approval_id": approval_id,
            "status": "pending",
            "operation": {
                "tool_name": operation.tool_name,
                "params": operation.params,
                "correlation_id": operation.correlation_id,
                "session_id": operation.session_id,
            },
            "risk_level": risk_level.value,
            "timeout": timeout,
            "created_at": operation.metadata.get("created_at"),
            "message": self._get_approval_message(operation, risk_level),
        }

    def _get_approval_message(self, operation: Operation, risk_level: RiskLevel) -> str:
        """Generate human-readable approval message."""
        messages = {
            RiskLevel.LOW: f"Allow {operation.tool_name} operation?",
            RiskLevel.MEDIUM: f"The agent wants to perform a medium-risk operation: {operation.tool_name}. This modification may be reversible. Approve?",
            RiskLevel.HIGH: f"⚠️ HIGH RISK: The agent wants to {operation.tool_name}. This operation is difficult to undo. Approve?",
            RiskLevel.CRITICAL: f"🚨 CRITICAL: The agent wants to {operation.tool_name}. This operation is IRREVERSIBLE and may result in DATA LOSS. Are you absolutely sure you want to approve?",
        }
        return messages.get(risk_level, f"Approve {operation.tool_name}?")

__init__()

Initialize API approval prompt.

Source code in src/harombe/security/hitl_prompt.py
def __init__(self):
    """Initialize API approval prompt."""
    self.pending_prompts = {}

create_prompt(approval_id, operation, risk_level, timeout)

Create API approval prompt data.

Parameters:

Name Type Description Default
approval_id str

Unique approval identifier

required
operation Operation

The operation requiring approval

required
risk_level RiskLevel

Risk level of the operation

required
timeout int

Timeout in seconds

required

Returns:

Type Description
dict

Dict with prompt data for API clients

Source code in src/harombe/security/hitl_prompt.py
def create_prompt(
    self,
    approval_id: str,
    operation: Operation,
    risk_level: RiskLevel,
    timeout: int,
) -> dict:
    """
    Create API approval prompt data.

    Args:
        approval_id: Unique approval identifier
        operation: The operation requiring approval
        risk_level: Risk level of the operation
        timeout: Timeout in seconds

    Returns:
        Dict with prompt data for API clients
    """
    return {
        "approval_id": approval_id,
        "status": "pending",
        "operation": {
            "tool_name": operation.tool_name,
            "params": operation.params,
            "correlation_id": operation.correlation_id,
            "session_id": operation.session_id,
        },
        "risk_level": risk_level.value,
        "timeout": timeout,
        "created_at": operation.metadata.get("created_at"),
        "message": self._get_approval_message(operation, risk_level),
    }

CLIApprovalPrompt

CLI-based approval prompts.

Source code in src/harombe/security/hitl_prompt.py
class CLIApprovalPrompt:
    """CLI-based approval prompts."""

    def __init__(self, console: Console | None = None):
        """
        Initialize CLI approval prompt.

        Args:
            console: Rich console for output
        """
        self.console = console or Console()

    async def prompt(
        self,
        operation: Operation,
        risk_level: RiskLevel,
        timeout: int,
        user: str = "user",
    ) -> ApprovalDecision:
        """
        Prompt user for approval via CLI.

        Args:
            operation: The operation requiring approval
            risk_level: Risk level of the operation
            timeout: Timeout in seconds
            user: User being prompted

        Returns:
            Approval decision
        """
        # Display approval request
        self._display_approval_request(operation, risk_level, timeout)

        # Get user decision with timeout
        try:
            approved = await asyncio.wait_for(self._get_user_input(), timeout=timeout)

            if approved:
                return ApprovalDecision(
                    decision=ApprovalStatus.APPROVED,
                    user=user,
                    reason="Approved via CLI",
                )
            else:
                return ApprovalDecision(
                    decision=ApprovalStatus.DENIED,
                    user=user,
                    reason="Denied via CLI",
                )

        except TimeoutError:
            self.console.print(
                "\n[red]✗[/red] Request timed out. Operation denied.",
                style="bold",
            )
            return ApprovalDecision(
                decision=ApprovalStatus.TIMEOUT,
                user=user,
                reason=f"No response within {timeout} seconds",
                timeout_seconds=timeout,
            )

    def _display_approval_request(
        self, operation: Operation, risk_level: RiskLevel, timeout: int
    ) -> None:
        """Display approval request to user."""
        # Risk level styling
        risk_colors = {
            RiskLevel.LOW: "green",
            RiskLevel.MEDIUM: "yellow",
            RiskLevel.HIGH: "red",
            RiskLevel.CRITICAL: "bold red",
        }
        risk_color = risk_colors.get(risk_level, "yellow")

        # Risk descriptions
        risk_descriptions = {
            RiskLevel.LOW: "Read-only operation, safe to execute",
            RiskLevel.MEDIUM: "Modification with possible undo",
            RiskLevel.HIGH: "Destructive operation, hard to undo",
            RiskLevel.CRITICAL: "Irreversible operation, potential data loss",
        }
        risk_desc = risk_descriptions.get(risk_level, "Unknown risk level")

        # Create header
        header = f"[{risk_color}]{risk_level.value.upper()} RISK[/{risk_color}] - APPROVAL REQUIRED"

        # Create parameters table
        params_table = Table(show_header=False, box=None, padding=(0, 2))
        params_table.add_column("Key", style="cyan")
        params_table.add_column("Value", style="white")

        for key, value in operation.params.items():
            # Truncate long values
            value_str = str(value)
            if len(value_str) > 100:
                value_str = value_str[:97] + "..."
            params_table.add_row(key, value_str)

        # Create content
        content = f"""
[bold]Tool:[/bold] {operation.tool_name}

[bold]Parameters:[/bold]
{params_table}

[bold]Risk:[/bold] [{risk_color}]{risk_level.value.upper()}[/{risk_color}] - {risk_desc}

[bold yellow]Auto-deny in {timeout} seconds...[/bold yellow]
        """

        # Display panel
        panel = Panel(
            content.strip(),
            title=header,
            border_style=risk_color,
            padding=(1, 2),
        )

        self.console.print()
        self.console.print(panel)
        self.console.print()

    async def _get_user_input(self) -> bool:
        """Get user approval decision."""
        # Run blocking input in executor
        loop = asyncio.get_event_loop()
        approved = await loop.run_in_executor(
            None,
            lambda: Confirm.ask(
                "[bold]Approve this operation?[/bold]",
                default=False,
            ),
        )
        return approved

__init__(console=None)

Initialize CLI approval prompt.

Parameters:

Name Type Description Default
console Console | None

Rich console for output

None
Source code in src/harombe/security/hitl_prompt.py
def __init__(self, console: Console | None = None):
    """
    Initialize CLI approval prompt.

    Args:
        console: Rich console for output
    """
    self.console = console or Console()

prompt(operation, risk_level, timeout, user='user') async

Prompt user for approval via CLI.

Parameters:

Name Type Description Default
operation Operation

The operation requiring approval

required
risk_level RiskLevel

Risk level of the operation

required
timeout int

Timeout in seconds

required
user str

User being prompted

'user'

Returns:

Type Description
ApprovalDecision

Approval decision

Source code in src/harombe/security/hitl_prompt.py
async def prompt(
    self,
    operation: Operation,
    risk_level: RiskLevel,
    timeout: int,
    user: str = "user",
) -> ApprovalDecision:
    """
    Prompt user for approval via CLI.

    Args:
        operation: The operation requiring approval
        risk_level: Risk level of the operation
        timeout: Timeout in seconds
        user: User being prompted

    Returns:
        Approval decision
    """
    # Display approval request
    self._display_approval_request(operation, risk_level, timeout)

    # Get user decision with timeout
    try:
        approved = await asyncio.wait_for(self._get_user_input(), timeout=timeout)

        if approved:
            return ApprovalDecision(
                decision=ApprovalStatus.APPROVED,
                user=user,
                reason="Approved via CLI",
            )
        else:
            return ApprovalDecision(
                decision=ApprovalStatus.DENIED,
                user=user,
                reason="Denied via CLI",
            )

    except TimeoutError:
        self.console.print(
            "\n[red]✗[/red] Request timed out. Operation denied.",
            style="bold",
        )
        return ApprovalDecision(
            decision=ApprovalStatus.TIMEOUT,
            user=user,
            reason=f"No response within {timeout} seconds",
            timeout_seconds=timeout,
        )

DotEnvLoader

Secure .env file loader with secret scanning.

Loads environment variables from .env files with: - Secret detection and warnings - Variable expansion - Comment support - Secure parsing

Source code in src/harombe/security/injection.py
class DotEnvLoader:
    """Secure .env file loader with secret scanning.

    Loads environment variables from .env files with:
    - Secret detection and warnings
    - Variable expansion
    - Comment support
    - Secure parsing
    """

    def __init__(
        self,
        warn_on_secrets: bool = True,
    ):
        """Initialize .env loader.

        Args:
            warn_on_secrets: Warn if secrets detected in .env file
        """
        self.warn_on_secrets = warn_on_secrets

    def load(
        self,
        env_file: str | Path,
        override: bool = False,
    ) -> dict[str, str]:
        """Load environment variables from .env file.

        Args:
            env_file: Path to .env file
            override: Override existing environment variables

        Returns:
            Dictionary of loaded variables
        """
        env_file = Path(env_file)
        if not env_file.exists():
            raise FileNotFoundError(f".env file not found: {env_file}")

        variables: dict[str, str] = {}

        with open(env_file) as f:
            for line_num, line in enumerate(f, 1):
                # Strip whitespace and skip comments/empty lines
                line = line.strip()
                if not line or line.startswith("#"):
                    continue

                # Parse KEY=VALUE
                if "=" not in line:
                    print(f"Warning: Invalid line {line_num} in {env_file}: {line}")
                    continue

                key, value = line.split("=", 1)
                key = key.strip()
                value = value.strip()

                # Remove quotes
                if (value.startswith('"') and value.endswith('"')) or (
                    value.startswith("'") and value.endswith("'")
                ):
                    value = value[1:-1]

                # Variable expansion (support ${VAR} and $VAR)
                value = self._expand_variables(value, variables)

                variables[key] = value

                # Set in environment if override=True or not already set
                if override or key not in os.environ:
                    os.environ[key] = value

        # Warn if secrets detected
        if self.warn_on_secrets:
            self._check_for_secrets(variables, env_file)

        return variables

    def _expand_variables(
        self,
        value: str,
        variables: dict[str, str],
    ) -> str:
        """Expand ${VAR} and $VAR references.

        Args:
            value: Value to expand
            variables: Available variables

        Returns:
            Expanded value
        """
        import re

        # Expand ${VAR} style
        def replace_braces(match: re.Match) -> str:
            var_name = match.group(1)
            return variables.get(var_name, os.getenv(var_name, ""))

        value = re.sub(r"\$\{([A-Z_][A-Z0-9_]*)\}", replace_braces, value)

        # Expand $VAR style
        def replace_simple(match: re.Match) -> str:
            var_name = match.group(1)
            return variables.get(var_name, os.getenv(var_name, ""))

        value = re.sub(r"\$([A-Z_][A-Z0-9_]*)", replace_simple, value)

        return value

    def _check_for_secrets(
        self,
        variables: dict[str, str],
        env_file: Path,
    ) -> None:
        """Check for potential secrets in .env file.

        Args:
            variables: Loaded variables
            env_file: Path to .env file
        """
        from .secrets import SecretScanner

        scanner = SecretScanner(min_confidence=0.8)

        for key, value in variables.items():
            matches = scanner.scan(value)
            if matches:
                print(
                    f"[SECURITY WARNING] Potential secret in {env_file}: {key}=" f"{value[:10]}..."
                )
                print("  Recommendation: Move this secret to Vault and reference it via injection")

__init__(warn_on_secrets=True)

Initialize .env loader.

Parameters:

Name Type Description Default
warn_on_secrets bool

Warn if secrets detected in .env file

True
Source code in src/harombe/security/injection.py
def __init__(
    self,
    warn_on_secrets: bool = True,
):
    """Initialize .env loader.

    Args:
        warn_on_secrets: Warn if secrets detected in .env file
    """
    self.warn_on_secrets = warn_on_secrets

load(env_file, override=False)

Load environment variables from .env file.

Parameters:

Name Type Description Default
env_file str | Path

Path to .env file

required
override bool

Override existing environment variables

False

Returns:

Type Description
dict[str, str]

Dictionary of loaded variables

Source code in src/harombe/security/injection.py
def load(
    self,
    env_file: str | Path,
    override: bool = False,
) -> dict[str, str]:
    """Load environment variables from .env file.

    Args:
        env_file: Path to .env file
        override: Override existing environment variables

    Returns:
        Dictionary of loaded variables
    """
    env_file = Path(env_file)
    if not env_file.exists():
        raise FileNotFoundError(f".env file not found: {env_file}")

    variables: dict[str, str] = {}

    with open(env_file) as f:
        for line_num, line in enumerate(f, 1):
            # Strip whitespace and skip comments/empty lines
            line = line.strip()
            if not line or line.startswith("#"):
                continue

            # Parse KEY=VALUE
            if "=" not in line:
                print(f"Warning: Invalid line {line_num} in {env_file}: {line}")
                continue

            key, value = line.split("=", 1)
            key = key.strip()
            value = value.strip()

            # Remove quotes
            if (value.startswith('"') and value.endswith('"')) or (
                value.startswith("'") and value.endswith("'")
            ):
                value = value[1:-1]

            # Variable expansion (support ${VAR} and $VAR)
            value = self._expand_variables(value, variables)

            variables[key] = value

            # Set in environment if override=True or not already set
            if override or key not in os.environ:
                os.environ[key] = value

    # Warn if secrets detected
    if self.warn_on_secrets:
        self._check_for_secrets(variables, env_file)

    return variables

SecretInjector

Injects secrets from vault into container environments.

Workflow: 1. Read secret mapping from configuration 2. Fetch secrets from vault backend 3. Generate temporary .env file (secure permissions) 4. Mount into container at startup 5. Clean up after container stops

Source code in src/harombe/security/injection.py
class SecretInjector:
    """Injects secrets from vault into container environments.

    Workflow:
    1. Read secret mapping from configuration
    2. Fetch secrets from vault backend
    3. Generate temporary .env file (secure permissions)
    4. Mount into container at startup
    5. Clean up after container stops
    """

    def __init__(
        self,
        vault_backend: VaultBackend,
        temp_dir: str = "/tmp/harombe-secrets",
    ):
        """Initialize secret injector.

        Args:
            vault_backend: Vault backend instance
            temp_dir: Directory for temporary secret files
        """
        self.vault = vault_backend
        self.temp_dir = Path(temp_dir)
        self.temp_dir.mkdir(parents=True, exist_ok=True, mode=0o700)  # Owner only

    async def inject_secrets(
        self,
        container_name: str,
        secret_mapping: dict[str, str],
    ) -> Path:
        """Create .env file with secrets for container.

        Args:
            container_name: Container name (for isolation)
            secret_mapping: Map of env_var_name -> vault_key

        Returns:
            Path to generated .env file

        Example:
            secret_mapping = {
                "GITHUB_TOKEN": "github/api-token",
                "SLACK_WEBHOOK": "slack/webhook-url",
            }
        """
        # Create container-specific temp file
        env_file = self.temp_dir / f"{container_name}.env"

        # Fetch secrets from vault
        env_vars: dict[str, str] = {}
        for env_name, vault_key in secret_mapping.items():
            secret_value = await self.vault.get_secret(vault_key)
            if secret_value is None:
                raise ValueError(f"Secret '{vault_key}' not found in vault")
            env_vars[env_name] = secret_value

        # Write to temp file with secure permissions
        with open(env_file, "w") as f:
            for key, value in env_vars.items():
                # Escape special characters for shell safety
                escaped_value = value.replace("\\", "\\\\").replace('"', '\\"')
                f.write(f'{key}="{escaped_value}"\n')

        # Set file permissions (owner read-only)
        os.chmod(env_file, 0o400)

        return env_file

    def cleanup(self, container_name: str) -> None:
        """Clean up secrets file for container.

        Args:
            container_name: Container name
        """
        env_file = self.temp_dir / f"{container_name}.env"
        if env_file.exists():
            # Overwrite with random data before deletion (paranoid security)
            with open(env_file, "wb") as f:
                f.write(os.urandom(env_file.stat().st_size))
            env_file.unlink()

    def cleanup_all(self) -> None:
        """Clean up all secret files."""
        for env_file in self.temp_dir.glob("*.env"):
            # Overwrite with random data
            with open(env_file, "wb") as f:
                f.write(os.urandom(env_file.stat().st_size))
            env_file.unlink()

__init__(vault_backend, temp_dir='/tmp/harombe-secrets')

Initialize secret injector.

Parameters:

Name Type Description Default
vault_backend VaultBackend

Vault backend instance

required
temp_dir str

Directory for temporary secret files

'/tmp/harombe-secrets'
Source code in src/harombe/security/injection.py
def __init__(
    self,
    vault_backend: VaultBackend,
    temp_dir: str = "/tmp/harombe-secrets",
):
    """Initialize secret injector.

    Args:
        vault_backend: Vault backend instance
        temp_dir: Directory for temporary secret files
    """
    self.vault = vault_backend
    self.temp_dir = Path(temp_dir)
    self.temp_dir.mkdir(parents=True, exist_ok=True, mode=0o700)  # Owner only

inject_secrets(container_name, secret_mapping) async

Create .env file with secrets for container.

Parameters:

Name Type Description Default
container_name str

Container name (for isolation)

required
secret_mapping dict[str, str]

Map of env_var_name -> vault_key

required

Returns:

Type Description
Path

Path to generated .env file

Example

secret_mapping = { "GITHUB_TOKEN": "github/api-token", "SLACK_WEBHOOK": "slack/webhook-url", }

Source code in src/harombe/security/injection.py
async def inject_secrets(
    self,
    container_name: str,
    secret_mapping: dict[str, str],
) -> Path:
    """Create .env file with secrets for container.

    Args:
        container_name: Container name (for isolation)
        secret_mapping: Map of env_var_name -> vault_key

    Returns:
        Path to generated .env file

    Example:
        secret_mapping = {
            "GITHUB_TOKEN": "github/api-token",
            "SLACK_WEBHOOK": "slack/webhook-url",
        }
    """
    # Create container-specific temp file
    env_file = self.temp_dir / f"{container_name}.env"

    # Fetch secrets from vault
    env_vars: dict[str, str] = {}
    for env_name, vault_key in secret_mapping.items():
        secret_value = await self.vault.get_secret(vault_key)
        if secret_value is None:
            raise ValueError(f"Secret '{vault_key}' not found in vault")
        env_vars[env_name] = secret_value

    # Write to temp file with secure permissions
    with open(env_file, "w") as f:
        for key, value in env_vars.items():
            # Escape special characters for shell safety
            escaped_value = value.replace("\\", "\\\\").replace('"', '\\"')
            f.write(f'{key}="{escaped_value}"\n')

    # Set file permissions (owner read-only)
    os.chmod(env_file, 0o400)

    return env_file

cleanup(container_name)

Clean up secrets file for container.

Parameters:

Name Type Description Default
container_name str

Container name

required
Source code in src/harombe/security/injection.py
def cleanup(self, container_name: str) -> None:
    """Clean up secrets file for container.

    Args:
        container_name: Container name
    """
    env_file = self.temp_dir / f"{container_name}.env"
    if env_file.exists():
        # Overwrite with random data before deletion (paranoid security)
        with open(env_file, "wb") as f:
            f.write(os.urandom(env_file.stat().st_size))
        env_file.unlink()

cleanup_all()

Clean up all secret files.

Source code in src/harombe/security/injection.py
def cleanup_all(self) -> None:
    """Clean up all secret files."""
    for env_file in self.temp_dir.glob("*.env"):
        # Overwrite with random data
        with open(env_file, "wb") as f:
            f.write(os.urandom(env_file.stat().st_size))
        env_file.unlink()

SecretRotationScheduler

Schedules and manages secret rotation.

Features: - Automatic rotation based on policies - Graceful rotation (no downtime) - Rotation audit trail

Source code in src/harombe/security/injection.py
class SecretRotationScheduler:
    """Schedules and manages secret rotation.

    Features:
    - Automatic rotation based on policies
    - Graceful rotation (no downtime)
    - Rotation audit trail
    """

    def __init__(
        self,
        vault_backend: VaultBackend,
        injector: SecretInjector,
    ):
        """Initialize rotation scheduler.

        Args:
            vault_backend: Vault backend
            injector: Secret injector
        """
        self.vault = vault_backend
        self.injector = injector
        self.rotation_policies: dict[str, str] = {}  # secret_key -> policy (e.g., "30d")

    def add_policy(self, secret_key: str, policy: str) -> None:
        """Add rotation policy for a secret.

        Args:
            secret_key: Vault secret key
            policy: Rotation policy (e.g., "30d", "90d")
        """
        self.rotation_policies[secret_key] = policy

    async def rotate_secret(
        self,
        secret_key: str,
        generator: Callable[[], str] | None = None,
    ) -> None:
        """Rotate a secret.

        Args:
            secret_key: Vault secret key
            generator: Optional function to generate new secret value
        """
        # Generate new secret value
        if generator:
            new_value = generator()
        else:
            # Default: use vault's rotation mechanism
            await self.vault.rotate_secret(secret_key)
            return

        # Store new secret
        await self.vault.set_secret(secret_key, new_value)

        # TODO: Trigger container restart to pick up new secret
        # This would integrate with DockerManager

    async def check_and_rotate(self) -> None:
        """Check all policies and rotate as needed.

        Called periodically by background task.
        """
        # TODO: Implement policy checking and automatic rotation
        # This would check last rotation time and trigger rotation
        # based on policy (e.g., every 30 days)
        pass

__init__(vault_backend, injector)

Initialize rotation scheduler.

Parameters:

Name Type Description Default
vault_backend VaultBackend

Vault backend

required
injector SecretInjector

Secret injector

required
Source code in src/harombe/security/injection.py
def __init__(
    self,
    vault_backend: VaultBackend,
    injector: SecretInjector,
):
    """Initialize rotation scheduler.

    Args:
        vault_backend: Vault backend
        injector: Secret injector
    """
    self.vault = vault_backend
    self.injector = injector
    self.rotation_policies: dict[str, str] = {}  # secret_key -> policy (e.g., "30d")

add_policy(secret_key, policy)

Add rotation policy for a secret.

Parameters:

Name Type Description Default
secret_key str

Vault secret key

required
policy str

Rotation policy (e.g., "30d", "90d")

required
Source code in src/harombe/security/injection.py
def add_policy(self, secret_key: str, policy: str) -> None:
    """Add rotation policy for a secret.

    Args:
        secret_key: Vault secret key
        policy: Rotation policy (e.g., "30d", "90d")
    """
    self.rotation_policies[secret_key] = policy

rotate_secret(secret_key, generator=None) async

Rotate a secret.

Parameters:

Name Type Description Default
secret_key str

Vault secret key

required
generator Callable[[], str] | None

Optional function to generate new secret value

None
Source code in src/harombe/security/injection.py
async def rotate_secret(
    self,
    secret_key: str,
    generator: Callable[[], str] | None = None,
) -> None:
    """Rotate a secret.

    Args:
        secret_key: Vault secret key
        generator: Optional function to generate new secret value
    """
    # Generate new secret value
    if generator:
        new_value = generator()
    else:
        # Default: use vault's rotation mechanism
        await self.vault.rotate_secret(secret_key)
        return

    # Store new secret
    await self.vault.set_secret(secret_key, new_value)

check_and_rotate() async

Check all policies and rotate as needed.

Called periodically by background task.

Source code in src/harombe/security/injection.py
async def check_and_rotate(self) -> None:
    """Check all policies and rotate as needed.

    Called periodically by background task.
    """
    # TODO: Implement policy checking and automatic rotation
    # This would check last rotation time and trigger rotation
    # based on policy (e.g., every 30 days)
    pass

DNSResolver

DNS resolver with caching for performance.

Caches domain → IP resolutions to avoid repeated lookups. Supports both A (IPv4) and AAAA (IPv6) records.

Source code in src/harombe/security/network.py
class DNSResolver:
    """DNS resolver with caching for performance.

    Caches domain → IP resolutions to avoid repeated lookups.
    Supports both A (IPv4) and AAAA (IPv6) records.
    """

    def __init__(self, cache_ttl: int = 300):
        """Initialize DNS resolver.

        Args:
            cache_ttl: Cache TTL in seconds (default: 5 minutes)
        """
        self._cache: dict[str, DNSCacheEntry] = {}
        self._cache_ttl = cache_ttl

    def resolve(self, domain: str) -> list[str]:
        """Resolve domain to IP addresses.

        Args:
            domain: Domain name to resolve

        Returns:
            List of IP addresses (empty if resolution fails)
        """
        # Check cache
        if domain in self._cache:
            entry = self._cache[domain]
            if time.time() - entry.timestamp < entry.ttl:
                logger.debug(f"DNS cache hit for {domain}: {entry.ips}")
                return entry.ips

        # Resolve using system DNS
        ips = self._system_resolve(domain)

        # Update cache
        if ips:
            self._cache[domain] = DNSCacheEntry(
                domain=domain,
                ips=ips,
                timestamp=time.time(),
                ttl=self._cache_ttl,
            )

        return ips

    def _system_resolve(self, domain: str) -> list[str]:
        """Resolve domain using system DNS.

        Args:
            domain: Domain to resolve

        Returns:
            List of IP addresses
        """
        try:
            import dns.resolver

            ips = []

            # Try A records (IPv4)
            try:
                answers = dns.resolver.resolve(domain, "A")
                for rdata in answers:
                    ips.append(str(rdata))
            except dns.resolver.NXDOMAIN:
                pass
            except dns.resolver.NoAnswer:
                pass

            # Try AAAA records (IPv6)
            try:
                answers = dns.resolver.resolve(domain, "AAAA")
                for rdata in answers:
                    ips.append(str(rdata))
            except dns.resolver.NXDOMAIN:
                pass
            except dns.resolver.NoAnswer:
                pass

            logger.debug(f"Resolved {domain} to {ips}")
            return ips

        except ImportError:
            logger.warning("dnspython not installed, using basic resolution")
            return self._basic_resolve(domain)
        except Exception as e:
            logger.error(f"DNS resolution failed for {domain}: {e}")
            return []

    def _basic_resolve(self, domain: str) -> list[str]:
        """Fallback DNS resolution using socket library.

        Args:
            domain: Domain to resolve

        Returns:
            List of IP addresses
        """
        try:
            import socket

            result = socket.getaddrinfo(domain, None)
            ips = list({addr[4][0] for addr in result})
            return ips
        except Exception as e:
            logger.error(f"Basic DNS resolution failed for {domain}: {e}")
            return []

    def clear_cache(self) -> None:
        """Clear DNS resolution cache."""
        self._cache.clear()
        logger.info("DNS cache cleared")

__init__(cache_ttl=300)

Initialize DNS resolver.

Parameters:

Name Type Description Default
cache_ttl int

Cache TTL in seconds (default: 5 minutes)

300
Source code in src/harombe/security/network.py
def __init__(self, cache_ttl: int = 300):
    """Initialize DNS resolver.

    Args:
        cache_ttl: Cache TTL in seconds (default: 5 minutes)
    """
    self._cache: dict[str, DNSCacheEntry] = {}
    self._cache_ttl = cache_ttl

resolve(domain)

Resolve domain to IP addresses.

Parameters:

Name Type Description Default
domain str

Domain name to resolve

required

Returns:

Type Description
list[str]

List of IP addresses (empty if resolution fails)

Source code in src/harombe/security/network.py
def resolve(self, domain: str) -> list[str]:
    """Resolve domain to IP addresses.

    Args:
        domain: Domain name to resolve

    Returns:
        List of IP addresses (empty if resolution fails)
    """
    # Check cache
    if domain in self._cache:
        entry = self._cache[domain]
        if time.time() - entry.timestamp < entry.ttl:
            logger.debug(f"DNS cache hit for {domain}: {entry.ips}")
            return entry.ips

    # Resolve using system DNS
    ips = self._system_resolve(domain)

    # Update cache
    if ips:
        self._cache[domain] = DNSCacheEntry(
            domain=domain,
            ips=ips,
            timestamp=time.time(),
            ttl=self._cache_ttl,
        )

    return ips

clear_cache()

Clear DNS resolution cache.

Source code in src/harombe/security/network.py
def clear_cache(self) -> None:
    """Clear DNS resolution cache."""
    self._cache.clear()
    logger.info("DNS cache cleared")

EgressFilter

Determine if an egress connection should be allowed.

Performs: - Domain allowlist checking with wildcard support - IP allowlist checking - CIDR block matching - DNS resolution and caching - Performance optimized (<1ms overhead)

Source code in src/harombe/security/network.py
class EgressFilter:
    """Determine if an egress connection should be allowed.

    Performs:
    - Domain allowlist checking with wildcard support
    - IP allowlist checking
    - CIDR block matching
    - DNS resolution and caching
    - Performance optimized (<1ms overhead)
    """

    def __init__(self, policy: NetworkPolicy, dns_resolver: DNSResolver | None = None):
        """Initialize egress filter.

        Args:
            policy: Network policy to enforce
            dns_resolver: DNS resolver (creates new one if None)
        """
        self.policy = policy
        self.dns_resolver = dns_resolver or DNSResolver()

        # Validate policy on initialization
        errors = policy.validate_policy()
        if errors:
            logger.warning(f"Policy validation errors: {errors}")

    def is_allowed(self, destination: str, port: int | None = None) -> tuple[bool, str]:
        """Check if connection to destination is allowed.

        Args:
            destination: Domain name or IP address
            port: Destination port (optional)

        Returns:
            Tuple of (allowed: bool, reason: str)
        """
        start_time = time.time()

        # Allow DNS queries
        if port == 53 and self.policy.allow_dns:
            reason = "DNS query allowed by policy"
            logger.debug(f"{reason}: {destination}:{port}")
            return True, reason

        # Allow localhost
        if self.policy.allow_localhost and self._is_localhost(destination):
            reason = "Localhost connection allowed by policy"
            logger.debug(f"{reason}: {destination}")
            return True, reason

        # Check if destination is IP address
        if self._is_ip_address(destination):
            allowed = self.policy.matches_ip(destination)
            reason = "IP in allowlist" if allowed else "IP not in allowlist"
            elapsed = (time.time() - start_time) * 1000
            logger.debug(f"IP check for {destination}: {allowed} ({elapsed:.2f}ms)")
            return allowed, reason

        # Check domain against allowlist
        if self.policy.matches_domain(destination):
            reason = "Domain in allowlist"
            elapsed = (time.time() - start_time) * 1000
            logger.debug(f"Domain check for {destination}: allowed ({elapsed:.2f}ms)")
            return True, reason

        # Resolve domain to IPs and check if any match
        ips = self.dns_resolver.resolve(destination)
        for ip in ips:
            if self.policy.matches_ip(ip):
                reason = f"Domain resolves to allowed IP: {ip}"
                elapsed = (time.time() - start_time) * 1000
                logger.debug(f"DNS-based check for {destination}: allowed ({elapsed:.2f}ms)")
                return True, reason

        # Block by default
        reason = f"Destination {destination} not in allowlist"
        elapsed = (time.time() - start_time) * 1000
        logger.debug(f"Connection blocked: {destination} ({elapsed:.2f}ms)")
        return False, reason

    @staticmethod
    def _is_ip_address(destination: str) -> bool:
        """Check if destination is an IP address.

        Args:
            destination: String to check

        Returns:
            True if IP address, False otherwise
        """
        try:
            ipaddress.ip_address(destination)
            return True
        except ValueError:
            return False

    @staticmethod
    def _is_localhost(destination: str) -> bool:
        """Check if destination is localhost.

        Args:
            destination: Destination to check

        Returns:
            True if localhost, False otherwise
        """
        localhost_patterns = [
            "localhost",
            "127.0.0.1",
            "::1",
            "0.0.0.0",
        ]

        destination = destination.lower()
        return any(pattern in destination for pattern in localhost_patterns)

__init__(policy, dns_resolver=None)

Initialize egress filter.

Parameters:

Name Type Description Default
policy NetworkPolicy

Network policy to enforce

required
dns_resolver DNSResolver | None

DNS resolver (creates new one if None)

None
Source code in src/harombe/security/network.py
def __init__(self, policy: NetworkPolicy, dns_resolver: DNSResolver | None = None):
    """Initialize egress filter.

    Args:
        policy: Network policy to enforce
        dns_resolver: DNS resolver (creates new one if None)
    """
    self.policy = policy
    self.dns_resolver = dns_resolver or DNSResolver()

    # Validate policy on initialization
    errors = policy.validate_policy()
    if errors:
        logger.warning(f"Policy validation errors: {errors}")

is_allowed(destination, port=None)

Check if connection to destination is allowed.

Parameters:

Name Type Description Default
destination str

Domain name or IP address

required
port int | None

Destination port (optional)

None

Returns:

Type Description
tuple[bool, str]

Tuple of (allowed: bool, reason: str)

Source code in src/harombe/security/network.py
def is_allowed(self, destination: str, port: int | None = None) -> tuple[bool, str]:
    """Check if connection to destination is allowed.

    Args:
        destination: Domain name or IP address
        port: Destination port (optional)

    Returns:
        Tuple of (allowed: bool, reason: str)
    """
    start_time = time.time()

    # Allow DNS queries
    if port == 53 and self.policy.allow_dns:
        reason = "DNS query allowed by policy"
        logger.debug(f"{reason}: {destination}:{port}")
        return True, reason

    # Allow localhost
    if self.policy.allow_localhost and self._is_localhost(destination):
        reason = "Localhost connection allowed by policy"
        logger.debug(f"{reason}: {destination}")
        return True, reason

    # Check if destination is IP address
    if self._is_ip_address(destination):
        allowed = self.policy.matches_ip(destination)
        reason = "IP in allowlist" if allowed else "IP not in allowlist"
        elapsed = (time.time() - start_time) * 1000
        logger.debug(f"IP check for {destination}: {allowed} ({elapsed:.2f}ms)")
        return allowed, reason

    # Check domain against allowlist
    if self.policy.matches_domain(destination):
        reason = "Domain in allowlist"
        elapsed = (time.time() - start_time) * 1000
        logger.debug(f"Domain check for {destination}: allowed ({elapsed:.2f}ms)")
        return True, reason

    # Resolve domain to IPs and check if any match
    ips = self.dns_resolver.resolve(destination)
    for ip in ips:
        if self.policy.matches_ip(ip):
            reason = f"Domain resolves to allowed IP: {ip}"
            elapsed = (time.time() - start_time) * 1000
            logger.debug(f"DNS-based check for {destination}: allowed ({elapsed:.2f}ms)")
            return True, reason

    # Block by default
    reason = f"Destination {destination} not in allowlist"
    elapsed = (time.time() - start_time) * 1000
    logger.debug(f"Connection blocked: {destination} ({elapsed:.2f}ms)")
    return False, reason

NetworkIsolationManager

Manage Docker networks and iptables rules for container isolation.

Provides: - Custom Docker network per container - iptables-based egress filtering - DNS allowlisting - Network telemetry - Dynamic policy updates

Source code in src/harombe/security/network.py
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
class NetworkIsolationManager:
    """Manage Docker networks and iptables rules for container isolation.

    Provides:
    - Custom Docker network per container
    - iptables-based egress filtering
    - DNS allowlisting
    - Network telemetry
    - Dynamic policy updates
    """

    def __init__(
        self,
        audit_logger: AuditLogger | None = None,
        enable_iptables: bool = True,
    ):
        """Initialize network isolation manager.

        Args:
            audit_logger: Audit logger for security decisions
            enable_iptables: Enable iptables rules (requires root)
        """
        self.audit_logger = audit_logger
        self.enable_iptables = enable_iptables
        self.dns_resolver = DNSResolver()
        self.network_monitor = NetworkMonitor(audit_logger=audit_logger)

        # Container -> Policy mapping
        self._policies: dict[str, NetworkPolicy] = {}

        # Container -> EgressFilter mapping
        self._filters: dict[str, EgressFilter] = {}

        # Container -> Docker network name mapping
        self._networks: dict[str, str] = {}

        self._docker: Any = None

    def _get_docker_client(self) -> Any:
        """Get Docker client.

        Returns:
            Docker client

        Raises:
            ImportError: If docker package not installed
        """
        if self._docker is None:
            try:
                import docker

                self._docker = docker.from_env()
                logger.info("Connected to Docker daemon for network management")
            except ImportError as e:
                msg = "Docker SDK not installed. Install with: pip install 'harombe[docker]'"
                raise ImportError(msg) from e

        return self._docker

    async def create_isolated_network(
        self,
        container_name: str,
        policy: NetworkPolicy,
    ) -> str:
        """Create isolated Docker network for container.

        Args:
            container_name: Name of container
            policy: Network policy to enforce

        Returns:
            Network name

        Raises:
            Exception: If network creation fails
        """
        network_name = f"harombe-{container_name}-net"

        try:
            client = self._get_docker_client()

            # Check if network already exists
            try:
                client.networks.get(network_name)
                logger.info(f"Network {network_name} already exists")
                self._networks[container_name] = network_name
                return network_name
            except Exception:
                pass

            # Create network with isolation
            client.networks.create(
                name=network_name,
                driver="bridge",
                internal=False,  # Allow external connections (filtered by iptables)
                enable_ipv6=False,  # IPv6 support optional
                options={
                    "com.docker.network.bridge.name": network_name[:15],  # Max 15 chars
                },
            )

            logger.info(f"Created isolated network: {network_name}")

            # Store policy and create filter
            self._policies[container_name] = policy
            self._filters[container_name] = EgressFilter(policy, self.dns_resolver)
            self._networks[container_name] = network_name

            # Apply iptables rules if enabled
            if self.enable_iptables:
                await self._apply_iptables_rules(container_name, network_name, policy)

            return network_name

        except Exception as e:
            logger.error(f"Failed to create isolated network for {container_name}: {e}")
            raise

    async def _apply_iptables_rules(
        self,
        container_name: str,
        network_name: str,
        policy: NetworkPolicy,
    ) -> None:
        """Apply iptables rules for egress filtering.

        Args:
            container_name: Container name
            network_name: Docker network name
            policy: Network policy

        Note:
            Requires root/sudo privileges. Will warn if unable to apply rules.
        """
        try:
            # Get network interface name
            client = self._get_docker_client()
            network = client.networks.get(network_name)
            network_info = network.attrs

            # Extract bridge interface (typically br-<network_id>)
            bridge_name = network_info.get("Options", {}).get(
                "com.docker.network.bridge.name", f"br-{network_info['Id'][:12]}"
            )

            logger.info(f"Applying iptables rules for {network_name} on {bridge_name}")

            # Create custom chain for this container
            chain_name = f"HAROMBE_{container_name[:20].upper()}"  # Max chain name length

            # Create chain if not exists
            subprocess.run(
                ["iptables", "-N", chain_name],
                check=False,  # May already exist
                capture_output=True,
            )

            # Flush existing rules in chain
            subprocess.run(
                ["iptables", "-F", chain_name],
                check=True,
                capture_output=True,
            )

            # Allow localhost if enabled
            if policy.allow_localhost:
                subprocess.run(
                    [
                        "iptables",
                        "-A",
                        chain_name,
                        "-d",
                        "127.0.0.0/8",
                        "-j",
                        "ACCEPT",
                    ],
                    check=True,
                    capture_output=True,
                )

            # Allow DNS if enabled
            if policy.allow_dns:
                subprocess.run(
                    [
                        "iptables",
                        "-A",
                        chain_name,
                        "-p",
                        "udp",
                        "--dport",
                        "53",
                        "-j",
                        "ACCEPT",
                    ],
                    check=True,
                    capture_output=True,
                )
                subprocess.run(
                    [
                        "iptables",
                        "-A",
                        chain_name,
                        "-p",
                        "tcp",
                        "--dport",
                        "53",
                        "-j",
                        "ACCEPT",
                    ],
                    check=True,
                    capture_output=True,
                )

            # Allow specific IPs
            for ip in policy.allowed_ips:
                subprocess.run(
                    [
                        "iptables",
                        "-A",
                        chain_name,
                        "-d",
                        ip,
                        "-j",
                        "ACCEPT",
                    ],
                    check=True,
                    capture_output=True,
                )

            # Allow CIDR blocks
            for cidr in policy.allowed_cidrs:
                subprocess.run(
                    [
                        "iptables",
                        "-A",
                        chain_name,
                        "-d",
                        cidr,
                        "-j",
                        "ACCEPT",
                    ],
                    check=True,
                    capture_output=True,
                )

            # Block everything else if block_by_default
            if policy.block_by_default:
                subprocess.run(
                    [
                        "iptables",
                        "-A",
                        chain_name,
                        "-j",
                        "DROP",
                    ],
                    check=True,
                    capture_output=True,
                )

            # Link chain to FORWARD chain for this network
            subprocess.run(
                [
                    "iptables",
                    "-I",
                    "FORWARD",
                    "-i",
                    bridge_name,
                    "-j",
                    chain_name,
                ],
                check=True,
                capture_output=True,
            )

            logger.info(f"Applied iptables rules for {container_name}")

        except subprocess.CalledProcessError as e:
            logger.error(f"Failed to apply iptables rules: {e.stderr.decode()}")
            logger.warning("Network isolation may be incomplete - consider running with sudo")
        except Exception as e:
            logger.error(f"Error applying iptables rules: {e}")

    async def update_policy(
        self,
        container_name: str,
        policy: NetworkPolicy,
    ) -> None:
        """Update network policy for a container dynamically.

        Updates the policy without requiring container restart.

        Args:
            container_name: Container name
            policy: New network policy

        Raises:
            ValueError: If container not found
        """
        if container_name not in self._policies:
            raise ValueError(f"Container {container_name} not found in network manager")

        # Validate new policy
        errors = policy.validate_policy()
        if errors:
            raise ValueError(f"Invalid policy: {errors}")

        # Update policy and filter
        self._policies[container_name] = policy
        self._filters[container_name] = EgressFilter(policy, self.dns_resolver)

        logger.info(f"Updated network policy for {container_name}")

        # Re-apply iptables rules if enabled
        if self.enable_iptables and container_name in self._networks:
            network_name = self._networks[container_name]
            await self._apply_iptables_rules(container_name, network_name, policy)

    def check_connection(
        self,
        container_name: str,
        destination: str,
        port: int | None = None,
    ) -> tuple[bool, str]:
        """Check if a connection is allowed by policy.

        Args:
            container_name: Container attempting connection
            destination: Destination domain/IP
            port: Destination port

        Returns:
            Tuple of (allowed: bool, reason: str)
        """
        # Get filter for container
        if container_name not in self._filters:
            logger.warning(f"No policy found for {container_name}, denying by default")
            return False, "No network policy configured"

        egress_filter = self._filters[container_name]

        # Check if allowed
        allowed, reason = egress_filter.is_allowed(destination, port)

        # Record for monitoring
        self.network_monitor.record_connection(
            container_name=container_name,
            destination=destination,
            port=port,
            allowed=allowed,
            reason=reason,
        )

        return allowed, reason

    def get_metrics(self, container_name: str) -> NetworkMetrics | None:
        """Get network metrics for a container.

        Args:
            container_name: Container name

        Returns:
            Network metrics or None
        """
        return self.network_monitor.get_metrics(container_name)

    def get_all_metrics(self) -> dict[str, NetworkMetrics]:
        """Get metrics for all containers.

        Returns:
            Dictionary of container_name -> NetworkMetrics
        """
        return self.network_monitor.get_all_metrics()

    def get_recent_blocks(
        self, container_name: str | None = None, minutes: int = 5
    ) -> list[ConnectionAttempt]:
        """Get recent blocked connection attempts.

        Args:
            container_name: Filter by container (None for all)
            minutes: How many minutes of history

        Returns:
            List of blocked connection attempts
        """
        attempts = self.network_monitor.get_recent_attempts(container_name, minutes)
        return [attempt for attempt in attempts if not attempt.allowed]

    async def cleanup_network(self, container_name: str) -> None:
        """Clean up network resources for a container.

        Args:
            container_name: Container name
        """
        if container_name not in self._networks:
            logger.warning(f"No network found for {container_name}")
            return

        network_name = self._networks[container_name]

        try:
            # Remove iptables rules
            if self.enable_iptables:
                chain_name = f"HAROMBE_{container_name[:20].upper()}"
                subprocess.run(
                    ["iptables", "-D", "FORWARD", "-j", chain_name],
                    check=False,
                    capture_output=True,
                )
                subprocess.run(
                    ["iptables", "-F", chain_name],
                    check=False,
                    capture_output=True,
                )
                subprocess.run(
                    ["iptables", "-X", chain_name],
                    check=False,
                    capture_output=True,
                )

            # Remove Docker network
            client = self._get_docker_client()
            network = client.networks.get(network_name)
            network.remove()

            logger.info(f"Cleaned up network {network_name}")

        except Exception as e:
            logger.error(f"Error cleaning up network for {container_name}: {e}")

        # Clean up internal state
        self._policies.pop(container_name, None)
        self._filters.pop(container_name, None)
        self._networks.pop(container_name, None)

    async def cleanup_all(self) -> None:
        """Clean up all managed networks."""
        container_names = list(self._networks.keys())

        for container_name in container_names:
            try:
                await self.cleanup_network(container_name)
            except Exception as e:
                logger.error(f"Failed to cleanup network for {container_name}: {e}")

        logger.info("Cleaned up all networks")

__init__(audit_logger=None, enable_iptables=True)

Initialize network isolation manager.

Parameters:

Name Type Description Default
audit_logger AuditLogger | None

Audit logger for security decisions

None
enable_iptables bool

Enable iptables rules (requires root)

True
Source code in src/harombe/security/network.py
def __init__(
    self,
    audit_logger: AuditLogger | None = None,
    enable_iptables: bool = True,
):
    """Initialize network isolation manager.

    Args:
        audit_logger: Audit logger for security decisions
        enable_iptables: Enable iptables rules (requires root)
    """
    self.audit_logger = audit_logger
    self.enable_iptables = enable_iptables
    self.dns_resolver = DNSResolver()
    self.network_monitor = NetworkMonitor(audit_logger=audit_logger)

    # Container -> Policy mapping
    self._policies: dict[str, NetworkPolicy] = {}

    # Container -> EgressFilter mapping
    self._filters: dict[str, EgressFilter] = {}

    # Container -> Docker network name mapping
    self._networks: dict[str, str] = {}

    self._docker: Any = None

create_isolated_network(container_name, policy) async

Create isolated Docker network for container.

Parameters:

Name Type Description Default
container_name str

Name of container

required
policy NetworkPolicy

Network policy to enforce

required

Returns:

Type Description
str

Network name

Raises:

Type Description
Exception

If network creation fails

Source code in src/harombe/security/network.py
async def create_isolated_network(
    self,
    container_name: str,
    policy: NetworkPolicy,
) -> str:
    """Create isolated Docker network for container.

    Args:
        container_name: Name of container
        policy: Network policy to enforce

    Returns:
        Network name

    Raises:
        Exception: If network creation fails
    """
    network_name = f"harombe-{container_name}-net"

    try:
        client = self._get_docker_client()

        # Check if network already exists
        try:
            client.networks.get(network_name)
            logger.info(f"Network {network_name} already exists")
            self._networks[container_name] = network_name
            return network_name
        except Exception:
            pass

        # Create network with isolation
        client.networks.create(
            name=network_name,
            driver="bridge",
            internal=False,  # Allow external connections (filtered by iptables)
            enable_ipv6=False,  # IPv6 support optional
            options={
                "com.docker.network.bridge.name": network_name[:15],  # Max 15 chars
            },
        )

        logger.info(f"Created isolated network: {network_name}")

        # Store policy and create filter
        self._policies[container_name] = policy
        self._filters[container_name] = EgressFilter(policy, self.dns_resolver)
        self._networks[container_name] = network_name

        # Apply iptables rules if enabled
        if self.enable_iptables:
            await self._apply_iptables_rules(container_name, network_name, policy)

        return network_name

    except Exception as e:
        logger.error(f"Failed to create isolated network for {container_name}: {e}")
        raise

update_policy(container_name, policy) async

Update network policy for a container dynamically.

Updates the policy without requiring container restart.

Parameters:

Name Type Description Default
container_name str

Container name

required
policy NetworkPolicy

New network policy

required

Raises:

Type Description
ValueError

If container not found

Source code in src/harombe/security/network.py
async def update_policy(
    self,
    container_name: str,
    policy: NetworkPolicy,
) -> None:
    """Update network policy for a container dynamically.

    Updates the policy without requiring container restart.

    Args:
        container_name: Container name
        policy: New network policy

    Raises:
        ValueError: If container not found
    """
    if container_name not in self._policies:
        raise ValueError(f"Container {container_name} not found in network manager")

    # Validate new policy
    errors = policy.validate_policy()
    if errors:
        raise ValueError(f"Invalid policy: {errors}")

    # Update policy and filter
    self._policies[container_name] = policy
    self._filters[container_name] = EgressFilter(policy, self.dns_resolver)

    logger.info(f"Updated network policy for {container_name}")

    # Re-apply iptables rules if enabled
    if self.enable_iptables and container_name in self._networks:
        network_name = self._networks[container_name]
        await self._apply_iptables_rules(container_name, network_name, policy)

check_connection(container_name, destination, port=None)

Check if a connection is allowed by policy.

Parameters:

Name Type Description Default
container_name str

Container attempting connection

required
destination str

Destination domain/IP

required
port int | None

Destination port

None

Returns:

Type Description
tuple[bool, str]

Tuple of (allowed: bool, reason: str)

Source code in src/harombe/security/network.py
def check_connection(
    self,
    container_name: str,
    destination: str,
    port: int | None = None,
) -> tuple[bool, str]:
    """Check if a connection is allowed by policy.

    Args:
        container_name: Container attempting connection
        destination: Destination domain/IP
        port: Destination port

    Returns:
        Tuple of (allowed: bool, reason: str)
    """
    # Get filter for container
    if container_name not in self._filters:
        logger.warning(f"No policy found for {container_name}, denying by default")
        return False, "No network policy configured"

    egress_filter = self._filters[container_name]

    # Check if allowed
    allowed, reason = egress_filter.is_allowed(destination, port)

    # Record for monitoring
    self.network_monitor.record_connection(
        container_name=container_name,
        destination=destination,
        port=port,
        allowed=allowed,
        reason=reason,
    )

    return allowed, reason

get_metrics(container_name)

Get network metrics for a container.

Parameters:

Name Type Description Default
container_name str

Container name

required

Returns:

Type Description
NetworkMetrics | None

Network metrics or None

Source code in src/harombe/security/network.py
def get_metrics(self, container_name: str) -> NetworkMetrics | None:
    """Get network metrics for a container.

    Args:
        container_name: Container name

    Returns:
        Network metrics or None
    """
    return self.network_monitor.get_metrics(container_name)

get_all_metrics()

Get metrics for all containers.

Returns:

Type Description
dict[str, NetworkMetrics]

Dictionary of container_name -> NetworkMetrics

Source code in src/harombe/security/network.py
def get_all_metrics(self) -> dict[str, NetworkMetrics]:
    """Get metrics for all containers.

    Returns:
        Dictionary of container_name -> NetworkMetrics
    """
    return self.network_monitor.get_all_metrics()

get_recent_blocks(container_name=None, minutes=5)

Get recent blocked connection attempts.

Parameters:

Name Type Description Default
container_name str | None

Filter by container (None for all)

None
minutes int

How many minutes of history

5

Returns:

Type Description
list[ConnectionAttempt]

List of blocked connection attempts

Source code in src/harombe/security/network.py
def get_recent_blocks(
    self, container_name: str | None = None, minutes: int = 5
) -> list[ConnectionAttempt]:
    """Get recent blocked connection attempts.

    Args:
        container_name: Filter by container (None for all)
        minutes: How many minutes of history

    Returns:
        List of blocked connection attempts
    """
    attempts = self.network_monitor.get_recent_attempts(container_name, minutes)
    return [attempt for attempt in attempts if not attempt.allowed]

cleanup_network(container_name) async

Clean up network resources for a container.

Parameters:

Name Type Description Default
container_name str

Container name

required
Source code in src/harombe/security/network.py
async def cleanup_network(self, container_name: str) -> None:
    """Clean up network resources for a container.

    Args:
        container_name: Container name
    """
    if container_name not in self._networks:
        logger.warning(f"No network found for {container_name}")
        return

    network_name = self._networks[container_name]

    try:
        # Remove iptables rules
        if self.enable_iptables:
            chain_name = f"HAROMBE_{container_name[:20].upper()}"
            subprocess.run(
                ["iptables", "-D", "FORWARD", "-j", chain_name],
                check=False,
                capture_output=True,
            )
            subprocess.run(
                ["iptables", "-F", chain_name],
                check=False,
                capture_output=True,
            )
            subprocess.run(
                ["iptables", "-X", chain_name],
                check=False,
                capture_output=True,
            )

        # Remove Docker network
        client = self._get_docker_client()
        network = client.networks.get(network_name)
        network.remove()

        logger.info(f"Cleaned up network {network_name}")

    except Exception as e:
        logger.error(f"Error cleaning up network for {container_name}: {e}")

    # Clean up internal state
    self._policies.pop(container_name, None)
    self._filters.pop(container_name, None)
    self._networks.pop(container_name, None)

cleanup_all() async

Clean up all managed networks.

Source code in src/harombe/security/network.py
async def cleanup_all(self) -> None:
    """Clean up all managed networks."""
    container_names = list(self._networks.keys())

    for container_name in container_names:
        try:
            await self.cleanup_network(container_name)
        except Exception as e:
            logger.error(f"Failed to cleanup network for {container_name}: {e}")

    logger.info("Cleaned up all networks")

NetworkMetrics dataclass

Network usage metrics for a container.

Source code in src/harombe/security/network.py
@dataclass
class NetworkMetrics:
    """Network usage metrics for a container."""

    container_name: str
    start_time: float = field(default_factory=time.time)
    total_connections: int = 0
    allowed_connections: int = 0
    blocked_connections: int = 0
    bytes_sent: int = 0
    bytes_received: int = 0
    last_updated: float = field(default_factory=time.time)

NetworkMonitor

Monitor network activity and detect suspicious patterns.

Features: - Track connection attempts - Detect suspicious patterns (port scanning, rapid failures) - Integration with audit logger - Metrics collection - Alerting

Source code in src/harombe/security/network.py
class NetworkMonitor:
    """Monitor network activity and detect suspicious patterns.

    Features:
    - Track connection attempts
    - Detect suspicious patterns (port scanning, rapid failures)
    - Integration with audit logger
    - Metrics collection
    - Alerting
    """

    # Thresholds for suspicious activity
    MAX_BLOCKED_PER_MINUTE = 10
    MAX_UNIQUE_DESTINATIONS_PER_MINUTE = 20
    PORT_SCAN_THRESHOLD = 5  # Different ports to same IP

    def __init__(self, audit_logger: AuditLogger | None = None):
        """Initialize network monitor.

        Args:
            audit_logger: Audit logger for security decisions
        """
        self.audit_logger = audit_logger
        self._metrics: dict[str, NetworkMetrics] = {}
        self._connection_history: list[ConnectionAttempt] = []
        self._max_history_size = 1000

    def record_connection(
        self,
        container_name: str,
        destination: str,
        port: int | None,
        allowed: bool,
        reason: str,
    ) -> None:
        """Record a connection attempt.

        Args:
            container_name: Name of container
            destination: Destination domain/IP
            port: Destination port
            allowed: Whether connection was allowed
            reason: Reason for allow/deny decision
        """
        # Update metrics
        if container_name not in self._metrics:
            self._metrics[container_name] = NetworkMetrics(container_name=container_name)

        metrics = self._metrics[container_name]
        metrics.total_connections += 1
        metrics.last_updated = time.time()

        if allowed:
            metrics.allowed_connections += 1
        else:
            metrics.blocked_connections += 1

        # Record connection attempt
        attempt = ConnectionAttempt(
            timestamp=time.time(),
            container_name=container_name,
            destination=destination,
            port=port,
            allowed=allowed,
            reason=reason,
        )

        self._connection_history.append(attempt)

        # Trim history if too large
        if len(self._connection_history) > self._max_history_size:
            self._connection_history = self._connection_history[-self._max_history_size :]

        # Log blocked connections
        if not allowed:
            logger.warning(
                f"Blocked connection: {container_name} -> {destination}:{port} "
                f"(reason: {reason})"
            )

            # Log to audit if available
            if self.audit_logger:
                self.audit_logger.log_security_decision(
                    correlation_id=f"network-{int(time.time() * 1000)}",
                    decision_type="egress",
                    decision=SecurityDecision.DENY,
                    reason=reason,
                    actor=container_name,
                    context={
                        "destination": destination,
                        "port": port,
                        "timestamp": datetime.utcnow().isoformat(),
                    },
                )

        # Check for suspicious patterns
        self._check_suspicious_activity(container_name)

    def _check_suspicious_activity(self, container_name: str) -> None:
        """Check for suspicious network activity patterns.

        Args:
            container_name: Container to check
        """
        now = time.time()
        one_minute_ago = now - 60

        # Get recent attempts for this container
        recent_attempts = [
            attempt
            for attempt in self._connection_history
            if attempt.container_name == container_name and attempt.timestamp > one_minute_ago
        ]

        if not recent_attempts:
            return

        # Check for excessive blocked connections
        blocked_count = sum(1 for attempt in recent_attempts if not attempt.allowed)
        if blocked_count > self.MAX_BLOCKED_PER_MINUTE:
            self._alert_suspicious(
                container_name,
                "excessive_blocks",
                f"{blocked_count} blocked connections in last minute",
            )

        # Check for too many unique destinations
        unique_destinations = len({attempt.destination for attempt in recent_attempts})
        if unique_destinations > self.MAX_UNIQUE_DESTINATIONS_PER_MINUTE:
            self._alert_suspicious(
                container_name,
                "destination_scanning",
                f"{unique_destinations} unique destinations in last minute",
            )

        # Check for port scanning (many ports to same IP)
        destination_ports: dict[str, set[int]] = {}
        for attempt in recent_attempts:
            if attempt.port:
                if attempt.destination not in destination_ports:
                    destination_ports[attempt.destination] = set()
                destination_ports[attempt.destination].add(attempt.port)

        for destination, ports in destination_ports.items():
            if len(ports) >= self.PORT_SCAN_THRESHOLD:
                self._alert_suspicious(
                    container_name,
                    "port_scanning",
                    f"{len(ports)} different ports to {destination}",
                )

    def _alert_suspicious(self, container_name: str, pattern: str, details: str) -> None:
        """Alert on suspicious network activity.

        Args:
            container_name: Container exhibiting suspicious behavior
            pattern: Type of suspicious pattern
            details: Details about the suspicious activity
        """
        logger.error(f"SUSPICIOUS ACTIVITY: {container_name} - {pattern}: {details}")

        if self.audit_logger:
            self.audit_logger.log_security_decision(
                correlation_id=f"alert-{int(time.time() * 1000)}",
                decision_type="alert",
                decision=SecurityDecision.DENY,
                reason=f"Suspicious network activity: {pattern}",
                actor=container_name,
                context={
                    "pattern": pattern,
                    "details": details,
                    "timestamp": datetime.utcnow().isoformat(),
                },
            )

    def get_metrics(self, container_name: str) -> NetworkMetrics | None:
        """Get network metrics for a container.

        Args:
            container_name: Container name

        Returns:
            Network metrics or None if not found
        """
        return self._metrics.get(container_name)

    def get_all_metrics(self) -> dict[str, NetworkMetrics]:
        """Get metrics for all containers.

        Returns:
            Dictionary of container_name -> NetworkMetrics
        """
        return self._metrics.copy()

    def get_recent_attempts(
        self, container_name: str | None = None, minutes: int = 5
    ) -> list[ConnectionAttempt]:
        """Get recent connection attempts.

        Args:
            container_name: Filter by container (None for all)
            minutes: How many minutes of history to return

        Returns:
            List of connection attempts
        """
        cutoff = time.time() - (minutes * 60)

        attempts = [attempt for attempt in self._connection_history if attempt.timestamp > cutoff]

        if container_name:
            attempts = [attempt for attempt in attempts if attempt.container_name == container_name]

        return attempts

__init__(audit_logger=None)

Initialize network monitor.

Parameters:

Name Type Description Default
audit_logger AuditLogger | None

Audit logger for security decisions

None
Source code in src/harombe/security/network.py
def __init__(self, audit_logger: AuditLogger | None = None):
    """Initialize network monitor.

    Args:
        audit_logger: Audit logger for security decisions
    """
    self.audit_logger = audit_logger
    self._metrics: dict[str, NetworkMetrics] = {}
    self._connection_history: list[ConnectionAttempt] = []
    self._max_history_size = 1000

record_connection(container_name, destination, port, allowed, reason)

Record a connection attempt.

Parameters:

Name Type Description Default
container_name str

Name of container

required
destination str

Destination domain/IP

required
port int | None

Destination port

required
allowed bool

Whether connection was allowed

required
reason str

Reason for allow/deny decision

required
Source code in src/harombe/security/network.py
def record_connection(
    self,
    container_name: str,
    destination: str,
    port: int | None,
    allowed: bool,
    reason: str,
) -> None:
    """Record a connection attempt.

    Args:
        container_name: Name of container
        destination: Destination domain/IP
        port: Destination port
        allowed: Whether connection was allowed
        reason: Reason for allow/deny decision
    """
    # Update metrics
    if container_name not in self._metrics:
        self._metrics[container_name] = NetworkMetrics(container_name=container_name)

    metrics = self._metrics[container_name]
    metrics.total_connections += 1
    metrics.last_updated = time.time()

    if allowed:
        metrics.allowed_connections += 1
    else:
        metrics.blocked_connections += 1

    # Record connection attempt
    attempt = ConnectionAttempt(
        timestamp=time.time(),
        container_name=container_name,
        destination=destination,
        port=port,
        allowed=allowed,
        reason=reason,
    )

    self._connection_history.append(attempt)

    # Trim history if too large
    if len(self._connection_history) > self._max_history_size:
        self._connection_history = self._connection_history[-self._max_history_size :]

    # Log blocked connections
    if not allowed:
        logger.warning(
            f"Blocked connection: {container_name} -> {destination}:{port} "
            f"(reason: {reason})"
        )

        # Log to audit if available
        if self.audit_logger:
            self.audit_logger.log_security_decision(
                correlation_id=f"network-{int(time.time() * 1000)}",
                decision_type="egress",
                decision=SecurityDecision.DENY,
                reason=reason,
                actor=container_name,
                context={
                    "destination": destination,
                    "port": port,
                    "timestamp": datetime.utcnow().isoformat(),
                },
            )

    # Check for suspicious patterns
    self._check_suspicious_activity(container_name)

get_metrics(container_name)

Get network metrics for a container.

Parameters:

Name Type Description Default
container_name str

Container name

required

Returns:

Type Description
NetworkMetrics | None

Network metrics or None if not found

Source code in src/harombe/security/network.py
def get_metrics(self, container_name: str) -> NetworkMetrics | None:
    """Get network metrics for a container.

    Args:
        container_name: Container name

    Returns:
        Network metrics or None if not found
    """
    return self._metrics.get(container_name)

get_all_metrics()

Get metrics for all containers.

Returns:

Type Description
dict[str, NetworkMetrics]

Dictionary of container_name -> NetworkMetrics

Source code in src/harombe/security/network.py
def get_all_metrics(self) -> dict[str, NetworkMetrics]:
    """Get metrics for all containers.

    Returns:
        Dictionary of container_name -> NetworkMetrics
    """
    return self._metrics.copy()

get_recent_attempts(container_name=None, minutes=5)

Get recent connection attempts.

Parameters:

Name Type Description Default
container_name str | None

Filter by container (None for all)

None
minutes int

How many minutes of history to return

5

Returns:

Type Description
list[ConnectionAttempt]

List of connection attempts

Source code in src/harombe/security/network.py
def get_recent_attempts(
    self, container_name: str | None = None, minutes: int = 5
) -> list[ConnectionAttempt]:
    """Get recent connection attempts.

    Args:
        container_name: Filter by container (None for all)
        minutes: How many minutes of history to return

    Returns:
        List of connection attempts
    """
    cutoff = time.time() - (minutes * 60)

    attempts = [attempt for attempt in self._connection_history if attempt.timestamp > cutoff]

    if container_name:
        attempts = [attempt for attempt in attempts if attempt.container_name == container_name]

    return attempts

NetworkPolicy

Bases: BaseModel

Network egress policy for a container.

Defines what outbound network connections are allowed. Supports: - Domain allowlist with wildcards (*.github.com) - IP addresses (1.1.1.1) - CIDR blocks (192.168.0.0/16, 2001:db8::/32) - DNS resolution caching for performance - Policy validation

Source code in src/harombe/security/network.py
class NetworkPolicy(BaseModel):
    """Network egress policy for a container.

    Defines what outbound network connections are allowed. Supports:
    - Domain allowlist with wildcards (*.github.com)
    - IP addresses (1.1.1.1)
    - CIDR blocks (192.168.0.0/16, 2001:db8::/32)
    - DNS resolution caching for performance
    - Policy validation
    """

    allowed_domains: list[str] = Field(
        default_factory=list,
        description="Allowed domains with wildcard support (e.g., '*.github.com', 'api.openai.com')",
    )
    allowed_ips: list[str] = Field(
        default_factory=list,
        description="Allowed IP addresses (e.g., '1.1.1.1', '8.8.8.8')",
    )
    allowed_cidrs: list[str] = Field(
        default_factory=list,
        description="Allowed CIDR blocks (e.g., '192.168.0.0/16', '10.0.0.0/8')",
    )
    block_by_default: bool = Field(
        default=True,
        description="Block all connections not explicitly allowed",
    )
    allow_dns: bool = Field(
        default=True,
        description="Allow DNS queries (port 53)",
    )
    allow_localhost: bool = Field(
        default=True,
        description="Allow connections to localhost/127.0.0.1",
    )

    def validate_policy(self) -> list[str]:
        """Validate policy configuration.

        Returns:
            List of validation errors (empty if valid)
        """
        errors = []

        # Validate domain patterns
        for domain in self.allowed_domains:
            if not self._is_valid_domain_pattern(domain):
                errors.append(f"Invalid domain pattern: {domain}")

        # Validate IP addresses
        for ip in self.allowed_ips:
            try:
                ipaddress.ip_address(ip)
            except ValueError:
                errors.append(f"Invalid IP address: {ip}")

        # Validate CIDR blocks
        for cidr in self.allowed_cidrs:
            try:
                ipaddress.ip_network(cidr, strict=False)
            except ValueError:
                errors.append(f"Invalid CIDR block: {cidr}")

        return errors

    @staticmethod
    def _is_valid_domain_pattern(pattern: str) -> bool:
        """Check if domain pattern is valid.

        Supports:
        - Regular domains: example.com
        - Wildcards: *.example.com
        - Subdomains: api.example.com

        Args:
            pattern: Domain pattern to validate

        Returns:
            True if valid, False otherwise
        """
        # Remove leading wildcard for validation
        domain = pattern.lstrip("*.")

        # Simple validation: must have at least one dot and valid characters
        if "." not in domain:
            return False

        # Check for invalid characters
        valid_pattern = re.compile(r"^[a-zA-Z0-9\-\.]+$")
        return bool(valid_pattern.match(domain))

    def matches_domain(self, domain: str) -> bool:
        """Check if domain matches any allowed pattern.

        Args:
            domain: Domain to check

        Returns:
            True if allowed, False otherwise
        """
        domain = domain.lower().strip()

        for pattern in self.allowed_domains:
            pattern = pattern.lower().strip()

            # Exact match
            if pattern == domain:
                return True

            # Wildcard match (*.example.com matches api.example.com)
            if pattern.startswith("*."):
                suffix = pattern[2:]  # Remove "*."
                if domain.endswith(suffix) or domain == suffix:
                    return True

        return False

    def matches_ip(self, ip: str) -> bool:
        """Check if IP address is allowed.

        Args:
            ip: IP address to check

        Returns:
            True if allowed, False otherwise
        """
        try:
            ip_addr = ipaddress.ip_address(ip)

            # Check exact IP matches
            for allowed_ip in self.allowed_ips:
                if ip_addr == ipaddress.ip_address(allowed_ip):
                    return True

            # Check CIDR blocks
            for cidr in self.allowed_cidrs:
                network = ipaddress.ip_network(cidr, strict=False)
                if ip_addr in network:
                    return True

        except ValueError:
            logger.warning(f"Invalid IP address format: {ip}")
            return False

        return False

validate_policy()

Validate policy configuration.

Returns:

Type Description
list[str]

List of validation errors (empty if valid)

Source code in src/harombe/security/network.py
def validate_policy(self) -> list[str]:
    """Validate policy configuration.

    Returns:
        List of validation errors (empty if valid)
    """
    errors = []

    # Validate domain patterns
    for domain in self.allowed_domains:
        if not self._is_valid_domain_pattern(domain):
            errors.append(f"Invalid domain pattern: {domain}")

    # Validate IP addresses
    for ip in self.allowed_ips:
        try:
            ipaddress.ip_address(ip)
        except ValueError:
            errors.append(f"Invalid IP address: {ip}")

    # Validate CIDR blocks
    for cidr in self.allowed_cidrs:
        try:
            ipaddress.ip_network(cidr, strict=False)
        except ValueError:
            errors.append(f"Invalid CIDR block: {cidr}")

    return errors

matches_domain(domain)

Check if domain matches any allowed pattern.

Parameters:

Name Type Description Default
domain str

Domain to check

required

Returns:

Type Description
bool

True if allowed, False otherwise

Source code in src/harombe/security/network.py
def matches_domain(self, domain: str) -> bool:
    """Check if domain matches any allowed pattern.

    Args:
        domain: Domain to check

    Returns:
        True if allowed, False otherwise
    """
    domain = domain.lower().strip()

    for pattern in self.allowed_domains:
        pattern = pattern.lower().strip()

        # Exact match
        if pattern == domain:
            return True

        # Wildcard match (*.example.com matches api.example.com)
        if pattern.startswith("*."):
            suffix = pattern[2:]  # Remove "*."
            if domain.endswith(suffix) or domain == suffix:
                return True

    return False

matches_ip(ip)

Check if IP address is allowed.

Parameters:

Name Type Description Default
ip str

IP address to check

required

Returns:

Type Description
bool

True if allowed, False otherwise

Source code in src/harombe/security/network.py
def matches_ip(self, ip: str) -> bool:
    """Check if IP address is allowed.

    Args:
        ip: IP address to check

    Returns:
        True if allowed, False otherwise
    """
    try:
        ip_addr = ipaddress.ip_address(ip)

        # Check exact IP matches
        for allowed_ip in self.allowed_ips:
            if ip_addr == ipaddress.ip_address(allowed_ip):
                return True

        # Check CIDR blocks
        for cidr in self.allowed_cidrs:
            network = ipaddress.ip_network(cidr, strict=False)
            if ip_addr in network:
                return True

    except ValueError:
        logger.warning(f"Invalid IP address format: {ip}")
        return False

    return False

FilterResult

Bases: BaseModel

Result of protocol filtering.

Attributes:

Name Type Description
allowed bool

Whether the packet is allowed

reason str

Human-readable reason for the decision

protocol Protocol

Detected protocol

details dict[str, Any]

Additional details about the filtering decision

duration_ms float | None

Time taken for filtering

Source code in src/harombe/security/protocol_filter.py
class FilterResult(BaseModel):
    """Result of protocol filtering.

    Attributes:
        allowed: Whether the packet is allowed
        reason: Human-readable reason for the decision
        protocol: Detected protocol
        details: Additional details about the filtering decision
        duration_ms: Time taken for filtering
    """

    allowed: bool
    reason: str
    protocol: Protocol = Protocol.UNKNOWN
    details: dict[str, Any] = Field(default_factory=dict)
    duration_ms: float | None = None

HTTPValidator

Validate HTTP/HTTPS request structure and content.

Checks: - HTTP method is allowed - Required headers are present - No forbidden headers - No request smuggling indicators - No suspicious URL patterns - Header size limits

Source code in src/harombe/security/protocol_filter.py
class HTTPValidator:
    """Validate HTTP/HTTPS request structure and content.

    Checks:
    - HTTP method is allowed
    - Required headers are present
    - No forbidden headers
    - No request smuggling indicators
    - No suspicious URL patterns
    - Header size limits
    """

    def __init__(self, policy: ProtocolPolicy):
        """Initialize HTTP validator.

        Args:
            policy: Protocol policy to enforce
        """
        self.policy = policy
        self._allowed_methods = frozenset(m.upper() for m in policy.allowed_http_methods)

    def parse_request(self, payload_text: str) -> HTTPRequest | None:
        """Parse HTTP request from payload text.

        Args:
            payload_text: Decoded payload text

        Returns:
            Parsed HTTPRequest or None if not a valid HTTP request
        """
        lines = payload_text.split("\n")
        if not lines:
            return None

        # Parse request line
        request_line = lines[0].rstrip("\r")
        parts = request_line.split(" ", 2)
        if len(parts) < 3:
            return None

        method, url, version = parts

        if method.upper() not in _VALID_HTTP_METHODS:
            return None

        if not version.upper().startswith("HTTP/"):
            return None

        # Parse headers
        headers: dict[str, str] = {}
        header_size = 0
        for line in lines[1:]:
            line = line.rstrip("\r")
            if not line:
                break
            if ":" in line:
                key, value = line.split(":", 1)
                headers[key.strip().lower()] = value.strip()
                header_size += len(line)

        # Check for WebSocket upgrade
        is_ws = (
            headers.get("upgrade", "").lower() == "websocket"
            and headers.get("connection", "").lower() == "upgrade"
        )

        return HTTPRequest(
            method=method.upper(),
            url=url,
            version=version,
            headers=headers,
            header_size=header_size,
            is_websocket_upgrade=is_ws,
        )

    def validate(self, request: HTTPRequest) -> FilterResult:
        """Validate HTTP request against policy.

        Args:
            request: Parsed HTTP request

        Returns:
            FilterResult with validation outcome
        """
        # Check HTTP method
        if request.method not in self._allowed_methods:
            return FilterResult(
                allowed=False,
                reason=f"HTTP method {request.method} not allowed",
                protocol=Protocol.HTTP,
                details={"method": request.method, "allowed": list(self._allowed_methods)},
            )

        # Check URL length
        if len(request.url) > self.policy.max_url_length:
            return FilterResult(
                allowed=False,
                reason=f"URL too long ({len(request.url)} > {self.policy.max_url_length})",
                protocol=Protocol.HTTP,
                details={"url_length": len(request.url)},
            )

        # Check required headers
        if self.policy.require_host_header:
            for header in _REQUIRED_HEADERS:
                if header not in request.headers:
                    return FilterResult(
                        allowed=False,
                        reason=f"Missing required header: {header}",
                        protocol=Protocol.HTTP,
                        details={"missing_header": header},
                    )

        # Check forbidden headers
        if self.policy.block_forbidden_headers:
            for header in _FORBIDDEN_HEADERS:
                if header in request.headers:
                    return FilterResult(
                        allowed=False,
                        reason=f"Forbidden header present: {header}",
                        protocol=Protocol.HTTP,
                        details={"forbidden_header": header},
                    )

        # Check header size
        if request.header_size > self.policy.max_header_size:
            return FilterResult(
                allowed=False,
                reason=f"Header size exceeds limit ({request.header_size} > {self.policy.max_header_size})",
                protocol=Protocol.HTTP,
                details={"header_size": request.header_size},
            )

        # Check for suspicious patterns in URL
        for pattern, description in _SUSPICIOUS_HTTP_PATTERNS:
            combined = f"{request.method} {request.url} {request.version}\r\n"
            for key, value in request.headers.items():
                combined += f"{key}: {value}\r\n"
            if pattern.search(combined):
                return FilterResult(
                    allowed=False,
                    reason=f"Suspicious HTTP pattern: {description}",
                    protocol=Protocol.HTTP,
                    details={"pattern": description},
                )

        return FilterResult(
            allowed=True,
            reason="HTTP request valid",
            protocol=Protocol.HTTP,
            details={
                "method": request.method,
                "url": request.url[:100],
                "is_websocket": request.is_websocket_upgrade,
            },
        )

__init__(policy)

Initialize HTTP validator.

Parameters:

Name Type Description Default
policy ProtocolPolicy

Protocol policy to enforce

required
Source code in src/harombe/security/protocol_filter.py
def __init__(self, policy: ProtocolPolicy):
    """Initialize HTTP validator.

    Args:
        policy: Protocol policy to enforce
    """
    self.policy = policy
    self._allowed_methods = frozenset(m.upper() for m in policy.allowed_http_methods)

parse_request(payload_text)

Parse HTTP request from payload text.

Parameters:

Name Type Description Default
payload_text str

Decoded payload text

required

Returns:

Type Description
HTTPRequest | None

Parsed HTTPRequest or None if not a valid HTTP request

Source code in src/harombe/security/protocol_filter.py
def parse_request(self, payload_text: str) -> HTTPRequest | None:
    """Parse HTTP request from payload text.

    Args:
        payload_text: Decoded payload text

    Returns:
        Parsed HTTPRequest or None if not a valid HTTP request
    """
    lines = payload_text.split("\n")
    if not lines:
        return None

    # Parse request line
    request_line = lines[0].rstrip("\r")
    parts = request_line.split(" ", 2)
    if len(parts) < 3:
        return None

    method, url, version = parts

    if method.upper() not in _VALID_HTTP_METHODS:
        return None

    if not version.upper().startswith("HTTP/"):
        return None

    # Parse headers
    headers: dict[str, str] = {}
    header_size = 0
    for line in lines[1:]:
        line = line.rstrip("\r")
        if not line:
            break
        if ":" in line:
            key, value = line.split(":", 1)
            headers[key.strip().lower()] = value.strip()
            header_size += len(line)

    # Check for WebSocket upgrade
    is_ws = (
        headers.get("upgrade", "").lower() == "websocket"
        and headers.get("connection", "").lower() == "upgrade"
    )

    return HTTPRequest(
        method=method.upper(),
        url=url,
        version=version,
        headers=headers,
        header_size=header_size,
        is_websocket_upgrade=is_ws,
    )

validate(request)

Validate HTTP request against policy.

Parameters:

Name Type Description Default
request HTTPRequest

Parsed HTTP request

required

Returns:

Type Description
FilterResult

FilterResult with validation outcome

Source code in src/harombe/security/protocol_filter.py
def validate(self, request: HTTPRequest) -> FilterResult:
    """Validate HTTP request against policy.

    Args:
        request: Parsed HTTP request

    Returns:
        FilterResult with validation outcome
    """
    # Check HTTP method
    if request.method not in self._allowed_methods:
        return FilterResult(
            allowed=False,
            reason=f"HTTP method {request.method} not allowed",
            protocol=Protocol.HTTP,
            details={"method": request.method, "allowed": list(self._allowed_methods)},
        )

    # Check URL length
    if len(request.url) > self.policy.max_url_length:
        return FilterResult(
            allowed=False,
            reason=f"URL too long ({len(request.url)} > {self.policy.max_url_length})",
            protocol=Protocol.HTTP,
            details={"url_length": len(request.url)},
        )

    # Check required headers
    if self.policy.require_host_header:
        for header in _REQUIRED_HEADERS:
            if header not in request.headers:
                return FilterResult(
                    allowed=False,
                    reason=f"Missing required header: {header}",
                    protocol=Protocol.HTTP,
                    details={"missing_header": header},
                )

    # Check forbidden headers
    if self.policy.block_forbidden_headers:
        for header in _FORBIDDEN_HEADERS:
            if header in request.headers:
                return FilterResult(
                    allowed=False,
                    reason=f"Forbidden header present: {header}",
                    protocol=Protocol.HTTP,
                    details={"forbidden_header": header},
                )

    # Check header size
    if request.header_size > self.policy.max_header_size:
        return FilterResult(
            allowed=False,
            reason=f"Header size exceeds limit ({request.header_size} > {self.policy.max_header_size})",
            protocol=Protocol.HTTP,
            details={"header_size": request.header_size},
        )

    # Check for suspicious patterns in URL
    for pattern, description in _SUSPICIOUS_HTTP_PATTERNS:
        combined = f"{request.method} {request.url} {request.version}\r\n"
        for key, value in request.headers.items():
            combined += f"{key}: {value}\r\n"
        if pattern.search(combined):
            return FilterResult(
                allowed=False,
                reason=f"Suspicious HTTP pattern: {description}",
                protocol=Protocol.HTTP,
                details={"pattern": description},
            )

    return FilterResult(
        allowed=True,
        reason="HTTP request valid",
        protocol=Protocol.HTTP,
        details={
            "method": request.method,
            "url": request.url[:100],
            "is_websocket": request.is_websocket_upgrade,
        },
    )

Protocol

Bases: StrEnum

Network protocol identifiers.

Attributes:

Name Type Description
HTTP

Hypertext Transfer Protocol

HTTPS

HTTP over TLS

DNS

Domain Name System

WEBSOCKET

WebSocket protocol

FTP

File Transfer Protocol

SSH

Secure Shell

SMTP

Simple Mail Transfer Protocol

UNKNOWN

Unrecognized protocol

Source code in src/harombe/security/protocol_filter.py
class Protocol(StrEnum):
    """Network protocol identifiers.

    Attributes:
        HTTP: Hypertext Transfer Protocol
        HTTPS: HTTP over TLS
        DNS: Domain Name System
        WEBSOCKET: WebSocket protocol
        FTP: File Transfer Protocol
        SSH: Secure Shell
        SMTP: Simple Mail Transfer Protocol
        UNKNOWN: Unrecognized protocol
    """

    HTTP = "http"
    HTTPS = "https"
    DNS = "dns"
    WEBSOCKET = "websocket"
    FTP = "ftp"
    SSH = "ssh"
    SMTP = "smtp"
    UNKNOWN = "unknown"

ProtocolFilter

Protocol-aware network traffic filter.

Detects the protocol in use and enforces protocol-level policies. Only permits allowed protocols with well-formed traffic.

Example

pf = ProtocolFilter() packet = NetworkPacket( ... source_ip="10.0.0.1", ... dest_ip="203.0.113.1", ... dest_port=443, ... payload=b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n", ... ) result = pf.filter(packet) print(result.allowed) True

Source code in src/harombe/security/protocol_filter.py
class ProtocolFilter:
    """Protocol-aware network traffic filter.

    Detects the protocol in use and enforces protocol-level policies.
    Only permits allowed protocols with well-formed traffic.

    Example:
        >>> pf = ProtocolFilter()
        >>> packet = NetworkPacket(
        ...     source_ip="10.0.0.1",
        ...     dest_ip="203.0.113.1",
        ...     dest_port=443,
        ...     payload=b"GET / HTTP/1.1\\r\\nHost: example.com\\r\\n\\r\\n",
        ... )
        >>> result = pf.filter(packet)
        >>> print(result.allowed)
        True
    """

    def __init__(self, policy: ProtocolPolicy | None = None):
        """Initialize protocol filter.

        Args:
            policy: Protocol policy to enforce (uses defaults if None)
        """
        self.policy = policy or ProtocolPolicy()
        self.http_validator = HTTPValidator(self.policy)
        self.stats: dict[str, int] = {
            "total_filtered": 0,
            "allowed": 0,
            "blocked": 0,
            "http_requests": 0,
            "protocol_violations": 0,
            "smuggling_attempts": 0,
        }

    def detect_protocol(self, packet: NetworkPacket) -> Protocol:
        """Detect the protocol from a network packet.

        Uses a combination of port mapping and payload inspection.

        Args:
            packet: Network packet to inspect

        Returns:
            Detected protocol
        """
        # Try payload-based detection first (more reliable)
        if packet.payload:
            payload_text = self._decode_payload(packet.payload)

            if payload_text:
                # Check for HTTP request
                if _HTTP_REQUEST_LINE.match(payload_text):
                    if packet.dest_port in (443, 8443):
                        return Protocol.HTTPS
                    return Protocol.HTTP

                # Check for HTTP response
                if _HTTP_RESPONSE_LINE.match(payload_text):
                    if packet.dest_port in (443, 8443):
                        return Protocol.HTTPS
                    return Protocol.HTTP

                # Check for SSH
                if _SSH_BANNER.match(payload_text):
                    return Protocol.SSH

                # Check for SMTP (before FTP since both use 220 greeting)
                # Use port hint to disambiguate when possible
                if _SMTP_GREETING.match(payload_text):
                    if packet.dest_port in (25, 587, 465):
                        return Protocol.SMTP
                    if _FTP_GREETING.match(payload_text) and packet.dest_port == 21:
                        return Protocol.FTP
                    return Protocol.SMTP

                # Check for FTP
                if _FTP_GREETING.match(payload_text):
                    return Protocol.FTP

        # Fall back to port-based detection
        if packet.dest_port is not None:
            protocol = _PORT_PROTOCOL_MAP.get(packet.dest_port)
            if protocol is not None:
                return protocol

        return Protocol.UNKNOWN

    def filter(self, packet: NetworkPacket) -> FilterResult:
        """Filter packet based on protocol policy.

        Args:
            packet: Network packet to filter

        Returns:
            FilterResult with allow/block decision
        """
        start = time.perf_counter()
        self.stats["total_filtered"] += 1

        # Detect protocol
        protocol = self.detect_protocol(packet)

        # Check if protocol is allowed
        if protocol == Protocol.UNKNOWN:
            # Unknown protocols are blocked unless the packet has no payload
            # (could be a SYN or other control packet)
            if packet.payload:
                self.stats["blocked"] += 1
                self.stats["protocol_violations"] += 1
                duration_ms = (time.perf_counter() - start) * 1000
                logger.warning(
                    f"Blocked unknown protocol: {packet.source_ip} -> "
                    f"{packet.dest_ip}:{packet.dest_port}"
                )
                return FilterResult(
                    allowed=False,
                    reason="Unknown protocol not allowed",
                    protocol=Protocol.UNKNOWN,
                    duration_ms=duration_ms,
                )
            # Allow empty-payload packets (connection setup)
            self.stats["allowed"] += 1
            duration_ms = (time.perf_counter() - start) * 1000
            return FilterResult(
                allowed=True,
                reason="Empty payload (connection setup)",
                protocol=Protocol.UNKNOWN,
                duration_ms=duration_ms,
            )

        if protocol not in self.policy.allowed_protocols:
            self.stats["blocked"] += 1
            self.stats["protocol_violations"] += 1
            duration_ms = (time.perf_counter() - start) * 1000
            logger.warning(
                f"Blocked disallowed protocol {protocol.value}: "
                f"{packet.source_ip} -> {packet.dest_ip}:{packet.dest_port}"
            )
            return FilterResult(
                allowed=False,
                reason=f"Protocol {protocol.value} not allowed",
                protocol=protocol,
                duration_ms=duration_ms,
            )

        # Protocol-specific validation
        if protocol in (Protocol.HTTP, Protocol.HTTPS):
            result = self._validate_http(packet, protocol)
            if result is not None:
                result.duration_ms = (time.perf_counter() - start) * 1000
                if result.allowed:
                    self.stats["allowed"] += 1
                else:
                    self.stats["blocked"] += 1
                return result

        # Allowed protocol with no further validation needed
        self.stats["allowed"] += 1
        duration_ms = (time.perf_counter() - start) * 1000
        return FilterResult(
            allowed=True,
            reason=f"Protocol {protocol.value} allowed",
            protocol=protocol,
            duration_ms=duration_ms,
        )

    def _validate_http(self, packet: NetworkPacket, protocol: Protocol) -> FilterResult | None:
        """Validate HTTP/HTTPS packet content.

        Args:
            packet: Network packet with HTTP payload
            protocol: Detected protocol (HTTP or HTTPS)

        Returns:
            FilterResult if validation produces a decision, None to fall through
        """
        self.stats["http_requests"] += 1

        payload_text = self._decode_payload(packet.payload)
        if not payload_text:
            return None

        # Parse HTTP request
        request = self.http_validator.parse_request(payload_text)
        if request is None:
            # Could not parse as HTTP - might be a TLS handshake or binary data
            return None

        # Check for request smuggling
        if self.policy.detect_smuggling:
            smuggling = self._check_smuggling(payload_text)
            if smuggling is not None:
                self.stats["smuggling_attempts"] += 1
                smuggling.protocol = protocol
                return smuggling

        # Validate the parsed request
        result = self.http_validator.validate(request)
        result.protocol = protocol
        return result

    def _check_smuggling(self, payload_text: str) -> FilterResult | None:
        """Check for HTTP request smuggling indicators.

        Args:
            payload_text: Decoded payload text

        Returns:
            FilterResult if smuggling detected, None otherwise
        """
        # Check for conflicting Content-Length and Transfer-Encoding
        has_cl = "content-length:" in payload_text.lower()
        has_te = "transfer-encoding:" in payload_text.lower()

        if has_cl and has_te:
            return FilterResult(
                allowed=False,
                reason="HTTP request smuggling: conflicting Content-Length and Transfer-Encoding",
                details={"smuggling_type": "CL-TE conflict"},
            )

        # Check for duplicate Content-Length headers
        cl_count = payload_text.lower().count("content-length:")
        if cl_count > 1:
            return FilterResult(
                allowed=False,
                reason="HTTP request smuggling: duplicate Content-Length headers",
                details={"smuggling_type": "duplicate CL"},
            )

        # Check for duplicate Transfer-Encoding headers
        te_count = payload_text.lower().count("transfer-encoding:")
        if te_count > 1:
            return FilterResult(
                allowed=False,
                reason="HTTP request smuggling: duplicate Transfer-Encoding headers",
                details={"smuggling_type": "duplicate TE"},
            )

        return None

    @staticmethod
    def _decode_payload(payload: bytes) -> str:
        """Decode payload bytes to text.

        Args:
            payload: Raw payload bytes

        Returns:
            Decoded text (UTF-8 with errors replaced)
        """
        try:
            return payload.decode("utf-8", errors="replace")
        except Exception:
            return ""

    def get_stats(self) -> dict[str, int]:
        """Get filtering statistics.

        Returns:
            Dictionary with operation counts
        """
        return self.stats.copy()

    def update_policy(self, policy: ProtocolPolicy) -> None:
        """Update the filtering policy.

        Args:
            policy: New protocol policy
        """
        self.policy = policy
        self.http_validator = HTTPValidator(policy)
        logger.info("Protocol filter policy updated")

__init__(policy=None)

Initialize protocol filter.

Parameters:

Name Type Description Default
policy ProtocolPolicy | None

Protocol policy to enforce (uses defaults if None)

None
Source code in src/harombe/security/protocol_filter.py
def __init__(self, policy: ProtocolPolicy | None = None):
    """Initialize protocol filter.

    Args:
        policy: Protocol policy to enforce (uses defaults if None)
    """
    self.policy = policy or ProtocolPolicy()
    self.http_validator = HTTPValidator(self.policy)
    self.stats: dict[str, int] = {
        "total_filtered": 0,
        "allowed": 0,
        "blocked": 0,
        "http_requests": 0,
        "protocol_violations": 0,
        "smuggling_attempts": 0,
    }

detect_protocol(packet)

Detect the protocol from a network packet.

Uses a combination of port mapping and payload inspection.

Parameters:

Name Type Description Default
packet NetworkPacket

Network packet to inspect

required

Returns:

Type Description
Protocol

Detected protocol

Source code in src/harombe/security/protocol_filter.py
def detect_protocol(self, packet: NetworkPacket) -> Protocol:
    """Detect the protocol from a network packet.

    Uses a combination of port mapping and payload inspection.

    Args:
        packet: Network packet to inspect

    Returns:
        Detected protocol
    """
    # Try payload-based detection first (more reliable)
    if packet.payload:
        payload_text = self._decode_payload(packet.payload)

        if payload_text:
            # Check for HTTP request
            if _HTTP_REQUEST_LINE.match(payload_text):
                if packet.dest_port in (443, 8443):
                    return Protocol.HTTPS
                return Protocol.HTTP

            # Check for HTTP response
            if _HTTP_RESPONSE_LINE.match(payload_text):
                if packet.dest_port in (443, 8443):
                    return Protocol.HTTPS
                return Protocol.HTTP

            # Check for SSH
            if _SSH_BANNER.match(payload_text):
                return Protocol.SSH

            # Check for SMTP (before FTP since both use 220 greeting)
            # Use port hint to disambiguate when possible
            if _SMTP_GREETING.match(payload_text):
                if packet.dest_port in (25, 587, 465):
                    return Protocol.SMTP
                if _FTP_GREETING.match(payload_text) and packet.dest_port == 21:
                    return Protocol.FTP
                return Protocol.SMTP

            # Check for FTP
            if _FTP_GREETING.match(payload_text):
                return Protocol.FTP

    # Fall back to port-based detection
    if packet.dest_port is not None:
        protocol = _PORT_PROTOCOL_MAP.get(packet.dest_port)
        if protocol is not None:
            return protocol

    return Protocol.UNKNOWN

filter(packet)

Filter packet based on protocol policy.

Parameters:

Name Type Description Default
packet NetworkPacket

Network packet to filter

required

Returns:

Type Description
FilterResult

FilterResult with allow/block decision

Source code in src/harombe/security/protocol_filter.py
def filter(self, packet: NetworkPacket) -> FilterResult:
    """Filter packet based on protocol policy.

    Args:
        packet: Network packet to filter

    Returns:
        FilterResult with allow/block decision
    """
    start = time.perf_counter()
    self.stats["total_filtered"] += 1

    # Detect protocol
    protocol = self.detect_protocol(packet)

    # Check if protocol is allowed
    if protocol == Protocol.UNKNOWN:
        # Unknown protocols are blocked unless the packet has no payload
        # (could be a SYN or other control packet)
        if packet.payload:
            self.stats["blocked"] += 1
            self.stats["protocol_violations"] += 1
            duration_ms = (time.perf_counter() - start) * 1000
            logger.warning(
                f"Blocked unknown protocol: {packet.source_ip} -> "
                f"{packet.dest_ip}:{packet.dest_port}"
            )
            return FilterResult(
                allowed=False,
                reason="Unknown protocol not allowed",
                protocol=Protocol.UNKNOWN,
                duration_ms=duration_ms,
            )
        # Allow empty-payload packets (connection setup)
        self.stats["allowed"] += 1
        duration_ms = (time.perf_counter() - start) * 1000
        return FilterResult(
            allowed=True,
            reason="Empty payload (connection setup)",
            protocol=Protocol.UNKNOWN,
            duration_ms=duration_ms,
        )

    if protocol not in self.policy.allowed_protocols:
        self.stats["blocked"] += 1
        self.stats["protocol_violations"] += 1
        duration_ms = (time.perf_counter() - start) * 1000
        logger.warning(
            f"Blocked disallowed protocol {protocol.value}: "
            f"{packet.source_ip} -> {packet.dest_ip}:{packet.dest_port}"
        )
        return FilterResult(
            allowed=False,
            reason=f"Protocol {protocol.value} not allowed",
            protocol=protocol,
            duration_ms=duration_ms,
        )

    # Protocol-specific validation
    if protocol in (Protocol.HTTP, Protocol.HTTPS):
        result = self._validate_http(packet, protocol)
        if result is not None:
            result.duration_ms = (time.perf_counter() - start) * 1000
            if result.allowed:
                self.stats["allowed"] += 1
            else:
                self.stats["blocked"] += 1
            return result

    # Allowed protocol with no further validation needed
    self.stats["allowed"] += 1
    duration_ms = (time.perf_counter() - start) * 1000
    return FilterResult(
        allowed=True,
        reason=f"Protocol {protocol.value} allowed",
        protocol=protocol,
        duration_ms=duration_ms,
    )

get_stats()

Get filtering statistics.

Returns:

Type Description
dict[str, int]

Dictionary with operation counts

Source code in src/harombe/security/protocol_filter.py
def get_stats(self) -> dict[str, int]:
    """Get filtering statistics.

    Returns:
        Dictionary with operation counts
    """
    return self.stats.copy()

update_policy(policy)

Update the filtering policy.

Parameters:

Name Type Description Default
policy ProtocolPolicy

New protocol policy

required
Source code in src/harombe/security/protocol_filter.py
def update_policy(self, policy: ProtocolPolicy) -> None:
    """Update the filtering policy.

    Args:
        policy: New protocol policy
    """
    self.policy = policy
    self.http_validator = HTTPValidator(policy)
    logger.info("Protocol filter policy updated")

ProtocolPolicy

Bases: BaseModel

Protocol filtering policy.

Attributes:

Name Type Description
allowed_protocols list[Protocol]

Protocols that are permitted

allowed_http_methods list[str]

HTTP methods that are permitted

require_host_header bool

Whether HTTP Host header is required

block_forbidden_headers bool

Whether to block requests with forbidden headers

detect_smuggling bool

Whether to detect HTTP request smuggling

max_header_size int

Maximum total header size in bytes

max_url_length int

Maximum URL length in characters

Source code in src/harombe/security/protocol_filter.py
class ProtocolPolicy(BaseModel):
    """Protocol filtering policy.

    Attributes:
        allowed_protocols: Protocols that are permitted
        allowed_http_methods: HTTP methods that are permitted
        require_host_header: Whether HTTP Host header is required
        block_forbidden_headers: Whether to block requests with forbidden headers
        detect_smuggling: Whether to detect HTTP request smuggling
        max_header_size: Maximum total header size in bytes
        max_url_length: Maximum URL length in characters
    """

    allowed_protocols: list[Protocol] = Field(
        default_factory=lambda: [Protocol.HTTP, Protocol.HTTPS, Protocol.DNS],
        description="Protocols that are permitted through the filter",
    )
    allowed_http_methods: list[str] = Field(
        default_factory=lambda: list(_DEFAULT_ALLOWED_METHODS),
        description="HTTP methods that are permitted",
    )
    require_host_header: bool = Field(
        default=True,
        description="Require Host header in HTTP requests",
    )
    block_forbidden_headers: bool = Field(
        default=True,
        description="Block requests containing forbidden headers",
    )
    detect_smuggling: bool = Field(
        default=True,
        description="Detect HTTP request smuggling attempts",
    )
    max_header_size: int = Field(
        default=8192,
        description="Maximum total header size in bytes",
    )
    max_url_length: int = Field(
        default=2048,
        description="Maximum URL length in characters",
    )

ExecutionResult dataclass

Result of code execution in sandbox.

Source code in src/harombe/security/sandbox_manager.py
@dataclass
class ExecutionResult:
    """Result of code execution in sandbox."""

    success: bool
    stdout: str
    stderr: str
    exit_code: int
    execution_time: float
    error: str | None = None

FileResult dataclass

Result of file operations.

Source code in src/harombe/security/sandbox_manager.py
@dataclass
class FileResult:
    """Result of file operations."""

    success: bool
    path: str
    content: str | None = None
    files: list[str] | None = None
    error: str | None = None

InstallResult dataclass

Result of package installation.

Source code in src/harombe/security/sandbox_manager.py
@dataclass
class InstallResult:
    """Result of package installation."""

    success: bool
    package: str
    registry: str
    stdout: str
    stderr: str
    error: str | None = None

Sandbox dataclass

Represents a gVisor sandbox instance.

Source code in src/harombe/security/sandbox_manager.py
@dataclass
class Sandbox:
    """Represents a gVisor sandbox instance."""

    sandbox_id: str
    language: str
    container_id: str | None = None
    network_enabled: bool = False
    allowed_domains: list[str] = field(default_factory=list)
    workspace_path: str | None = None
    created_at: float = field(default_factory=time.time)
    execution_count: int = 0

SandboxManager

Manages gVisor sandbox lifecycle and code execution.

Source code in src/harombe/security/sandbox_manager.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
class SandboxManager:
    """Manages gVisor sandbox lifecycle and code execution."""

    def __init__(
        self,
        docker_manager: DockerManager,
        runtime: str = "runsc",
        max_memory_mb: int = 512,
        max_cpu_cores: float = 0.5,
        max_disk_mb: int = 1024,
        max_execution_time: int = 30,
        max_output_bytes: int = 1_048_576,
    ):
        """Initialize sandbox manager.

        Args:
            docker_manager: Docker manager instance
            runtime: Container runtime (default: runsc for gVisor)
            max_memory_mb: Maximum memory per sandbox (MB)
            max_cpu_cores: Maximum CPU cores per sandbox
            max_disk_mb: Maximum disk space per sandbox (MB)
            max_execution_time: Maximum execution time per run (seconds)
            max_output_bytes: Maximum output size (bytes)
        """
        self.docker_manager = docker_manager
        self.runtime = runtime
        self.max_memory_mb = max_memory_mb
        self.max_cpu_cores = max_cpu_cores
        self.max_disk_mb = max_disk_mb
        self.max_execution_time = max_execution_time
        self.max_output_bytes = max_output_bytes

        # Active sandboxes
        self._sandboxes: dict[str, Sandbox] = {}

        # Language-specific images
        self._images = {
            "python": "python:3.11-slim",
            "javascript": "node:20-slim",
            "shell": "bash:5.2",
        }

    async def start(self) -> None:
        """Start the sandbox manager."""
        await self.docker_manager.start()
        logger.info("SandboxManager started with runtime=%s", self.runtime)

    async def stop(self) -> None:
        """Stop the sandbox manager and cleanup all sandboxes."""
        # Destroy all active sandboxes
        sandbox_ids = list(self._sandboxes.keys())
        for sandbox_id in sandbox_ids:
            try:
                await self.destroy_sandbox(sandbox_id)
            except Exception as e:
                logger.error(f"Error destroying sandbox {sandbox_id}: {e}")

        await self.docker_manager.stop()
        logger.info("SandboxManager stopped")

    async def create_sandbox(
        self,
        language: str,
        sandbox_id: str | None = None,
        network_enabled: bool = False,
        allowed_domains: list[str] | None = None,
    ) -> str:
        """Create a new gVisor sandbox.

        Args:
            language: Programming language (python, javascript, shell)
            sandbox_id: Optional sandbox ID (generated if not provided)
            network_enabled: Enable network access
            allowed_domains: Allowlisted domains (when network enabled)

        Returns:
            Sandbox ID

        Raises:
            ValueError: If language not supported or Docker not started
        """
        if language not in self._images:
            raise ValueError(
                f"Unsupported language: {language}. " f"Supported: {list(self._images.keys())}"
            )

        if not self.docker_manager.client:
            raise RuntimeError("Docker manager not started")

        # Generate sandbox ID
        if sandbox_id is None:
            sandbox_id = f"sandbox-{uuid.uuid4().hex[:8]}"

        # Create temporary workspace
        workspace_path = f"/tmp/harombe-sandbox-{sandbox_id}"
        Path(workspace_path).mkdir(parents=True, exist_ok=True)

        # Create sandbox instance
        sandbox = Sandbox(
            sandbox_id=sandbox_id,
            language=language,
            network_enabled=network_enabled,
            allowed_domains=allowed_domains or [],
            workspace_path=workspace_path,
        )

        self._sandboxes[sandbox_id] = sandbox

        logger.info(
            f"Created sandbox {sandbox_id} for {language} " f"(network_enabled={network_enabled})"
        )

        return sandbox_id

    async def execute_code(
        self,
        sandbox_id: str,
        code: str,
        timeout: int | None = None,
        max_memory_mb: int | None = None,
    ) -> ExecutionResult:
        """Execute code in sandbox.

        Args:
            sandbox_id: Sandbox ID
            code: Code to execute
            timeout: Execution timeout (uses default if not provided)
            max_memory_mb: Memory limit (uses default if not provided)

        Returns:
            Execution result with stdout, stderr, exit_code

        Raises:
            ValueError: If sandbox not found
        """
        sandbox = self._get_sandbox(sandbox_id)

        timeout = timeout or self.max_execution_time
        max_memory_mb = max_memory_mb or self.max_memory_mb

        # Write code to workspace
        code_file = self._get_code_filename(sandbox.language)
        code_path = Path(sandbox.workspace_path) / code_file
        code_path.write_text(code)

        # Get execution command
        command = self._get_execution_command(sandbox.language, code_file)

        # Create container configuration
        container_config = {
            "image": self._images[sandbox.language],
            "runtime": self.runtime,
            "command": command,
            "network_mode": "none" if not sandbox.network_enabled else "bridge",
            "mem_limit": f"{max_memory_mb}m",
            "cpu_period": 100000,
            "cpu_quota": int(self.max_cpu_cores * 100000),
            "volumes": {
                sandbox.workspace_path: {
                    "bind": "/workspace",
                    "mode": "rw",
                }
            },
            "working_dir": "/workspace",
            "remove": True,
            "detach": False,
        }

        start_time = time.time()

        try:
            # Run container
            result = await self._run_container(container_config, timeout)

            execution_time = time.time() - start_time

            # Truncate output if too large
            stdout = result["stdout"][: self.max_output_bytes]
            stderr = result["stderr"][: self.max_output_bytes]

            if len(result["stdout"]) > self.max_output_bytes:
                stdout += "\n[OUTPUT TRUNCATED]"
            if len(result["stderr"]) > self.max_output_bytes:
                stderr += "\n[OUTPUT TRUNCATED]"

            sandbox.execution_count += 1

            logger.info(
                f"Executed code in sandbox {sandbox_id} "
                f"(exit_code={result['exit_code']}, time={execution_time:.2f}s)"
            )

            return ExecutionResult(
                success=result["exit_code"] == 0,
                stdout=stdout,
                stderr=stderr,
                exit_code=result["exit_code"],
                execution_time=execution_time,
            )

        except TimeoutError:
            execution_time = time.time() - start_time
            logger.warning(f"Code execution timeout in sandbox {sandbox_id}")
            return ExecutionResult(
                success=False,
                stdout="",
                stderr=f"Execution timeout after {timeout}s",
                exit_code=-1,
                execution_time=execution_time,
                error="TimeoutError",
            )
        except Exception as e:
            execution_time = time.time() - start_time
            logger.error(f"Code execution error in sandbox {sandbox_id}: {e}")
            return ExecutionResult(
                success=False,
                stdout="",
                stderr=str(e),
                exit_code=-1,
                execution_time=execution_time,
                error=type(e).__name__,
            )

    async def install_package(
        self,
        sandbox_id: str,
        package: str,
        registry: str = "pypi",
    ) -> InstallResult:
        """Install package in sandbox.

        Args:
            sandbox_id: Sandbox ID
            package: Package name (with optional version)
            registry: Registry name (pypi, npm)

        Returns:
            Installation result

        Raises:
            ValueError: If sandbox not found or registry not supported
        """
        sandbox = self._get_sandbox(sandbox_id)

        if not sandbox.network_enabled:
            return InstallResult(
                success=False,
                package=package,
                registry=registry,
                stdout="",
                stderr="Network access required for package installation",
                error="NetworkDisabled",
            )

        # Get install command
        install_cmd = self._get_install_command(sandbox.language, package, registry)

        if not install_cmd:
            return InstallResult(
                success=False,
                package=package,
                registry=registry,
                stdout="",
                stderr=f"Package installation not supported for {sandbox.language}",
                error="UnsupportedLanguage",
            )

        # Create container configuration
        container_config = {
            "image": self._images[sandbox.language],
            "runtime": self.runtime,
            "command": install_cmd,
            "network_mode": "bridge",  # Network required
            "mem_limit": f"{self.max_memory_mb}m",
            "volumes": {
                sandbox.workspace_path: {
                    "bind": "/workspace",
                    "mode": "rw",
                }
            },
            "working_dir": "/workspace",
            "remove": True,
            "detach": False,
        }

        try:
            result = await self._run_container(container_config, timeout=300)

            logger.info(
                f"Installed package {package} from {registry} "
                f"in sandbox {sandbox_id} (exit_code={result['exit_code']})"
            )

            return InstallResult(
                success=result["exit_code"] == 0,
                package=package,
                registry=registry,
                stdout=result["stdout"],
                stderr=result["stderr"],
            )

        except Exception as e:
            logger.error(f"Package installation error in sandbox {sandbox_id}: {e}")
            return InstallResult(
                success=False,
                package=package,
                registry=registry,
                stdout="",
                stderr=str(e),
                error=type(e).__name__,
            )

    async def write_file(
        self,
        sandbox_id: str,
        file_path: str,
        content: str,
    ) -> FileResult:
        """Write file to sandbox workspace.

        Args:
            sandbox_id: Sandbox ID
            file_path: File path (relative to /workspace)
            content: File content

        Returns:
            Write result
        """
        sandbox = self._get_sandbox(sandbox_id)

        try:
            # Ensure path is relative and within workspace
            clean_path = self._sanitize_path(file_path)
            full_path = Path(sandbox.workspace_path) / clean_path

            # Create parent directories
            full_path.parent.mkdir(parents=True, exist_ok=True)

            # Write file
            full_path.write_text(content)

            logger.info(f"Wrote file {file_path} in sandbox {sandbox_id}")

            return FileResult(
                success=True,
                path=file_path,
            )

        except Exception as e:
            logger.error(f"Write file error in sandbox {sandbox_id}: {e}")
            return FileResult(
                success=False,
                path=file_path,
                error=str(e),
            )

    async def read_file(
        self,
        sandbox_id: str,
        file_path: str,
    ) -> FileResult:
        """Read file from sandbox workspace.

        Args:
            sandbox_id: Sandbox ID
            file_path: File path (relative to /workspace)

        Returns:
            Read result with file content
        """
        sandbox = self._get_sandbox(sandbox_id)

        try:
            # Ensure path is relative and within workspace
            clean_path = self._sanitize_path(file_path)
            full_path = Path(sandbox.workspace_path) / clean_path

            # Read file
            content = full_path.read_text()

            logger.info(f"Read file {file_path} from sandbox {sandbox_id}")

            return FileResult(
                success=True,
                path=file_path,
                content=content,
            )

        except FileNotFoundError:
            return FileResult(
                success=False,
                path=file_path,
                error=f"File not found: {file_path}",
            )
        except Exception as e:
            logger.error(f"Read file error in sandbox {sandbox_id}: {e}")
            return FileResult(
                success=False,
                path=file_path,
                error=str(e),
            )

    async def list_files(
        self,
        sandbox_id: str,
        path: str = ".",
    ) -> FileResult:
        """List files in sandbox workspace.

        Args:
            sandbox_id: Sandbox ID
            path: Directory path (relative to /workspace)

        Returns:
            List result with file names
        """
        sandbox = self._get_sandbox(sandbox_id)

        try:
            # Ensure path is relative and within workspace
            clean_path = self._sanitize_path(path)
            full_path = Path(sandbox.workspace_path) / clean_path

            # List files
            if full_path.is_dir():
                files = [str(p.relative_to(sandbox.workspace_path)) for p in full_path.iterdir()]
            else:
                return FileResult(
                    success=False,
                    path=path,
                    error=f"Not a directory: {path}",
                )

            logger.info(f"Listed files in {path} from sandbox {sandbox_id}")

            return FileResult(
                success=True,
                path=path,
                files=sorted(files),
            )

        except Exception as e:
            logger.error(f"List files error in sandbox {sandbox_id}: {e}")
            return FileResult(
                success=False,
                path=path,
                error=str(e),
            )

    async def destroy_sandbox(self, sandbox_id: str) -> None:
        """Destroy sandbox and cleanup resources.

        Args:
            sandbox_id: Sandbox ID

        Raises:
            ValueError: If sandbox not found
        """
        sandbox = self._get_sandbox(sandbox_id)

        # Cleanup workspace
        try:
            import shutil

            if sandbox.workspace_path and Path(sandbox.workspace_path).exists():
                shutil.rmtree(sandbox.workspace_path)
        except Exception as e:
            logger.warning(f"Error cleaning workspace for {sandbox_id}: {e}")

        # Remove from active sandboxes
        del self._sandboxes[sandbox_id]

        logger.info(f"Destroyed sandbox {sandbox_id}")

    def _get_sandbox(self, sandbox_id: str) -> Sandbox:
        """Get sandbox by ID.

        Args:
            sandbox_id: Sandbox ID

        Returns:
            Sandbox instance

        Raises:
            ValueError: If sandbox not found
        """
        if sandbox_id not in self._sandboxes:
            raise ValueError(f"Sandbox not found: {sandbox_id}")
        return self._sandboxes[sandbox_id]

    def _get_code_filename(self, language: str) -> str:
        """Get code filename for language."""
        filenames = {
            "python": "script.py",
            "javascript": "script.js",
            "shell": "script.sh",
        }
        return filenames[language]

    def _get_execution_command(self, language: str, code_file: str) -> list[str]:
        """Get execution command for language."""
        commands = {
            "python": ["python", code_file],
            "javascript": ["node", code_file],
            "shell": ["bash", code_file],
        }
        return commands[language]

    def _get_install_command(self, language: str, package: str, registry: str) -> list[str] | None:
        """Get package install command.

        Args:
            language: Programming language
            package: Package name
            registry: Registry name

        Returns:
            Install command or None if not supported
        """
        if language == "python" and registry == "pypi":
            return ["pip", "install", "--target=/workspace/.packages", package]
        elif language == "javascript" and registry == "npm":
            return ["npm", "install", "--prefix=/workspace", package]
        return None

    def _sanitize_path(self, path: str) -> str:
        """Sanitize file path to prevent directory traversal.

        Args:
            path: Input path

        Returns:
            Sanitized path

        Raises:
            ValueError: If path attempts directory traversal
        """
        # Remove leading slash
        clean_path = path.lstrip("/")

        # Check for directory traversal
        if ".." in clean_path or clean_path.startswith("/"):
            raise ValueError(f"Invalid path: {path}")

        return clean_path

    async def _run_container(self, config: dict[str, Any], timeout: int) -> dict[str, Any]:
        """Run container and capture output.

        Args:
            config: Container configuration
            timeout: Execution timeout (seconds)

        Returns:
            Result with stdout, stderr, exit_code
        """
        if not self.docker_manager.client:
            raise RuntimeError("Docker manager not started")

        # Create container
        container = await asyncio.to_thread(self.docker_manager.client.containers.create, **config)

        try:
            # Start container with timeout
            await asyncio.wait_for(
                asyncio.to_thread(container.start),
                timeout=timeout,
            )

            # Wait for completion
            result = await asyncio.wait_for(
                asyncio.to_thread(container.wait),
                timeout=timeout,
            )

            # Get logs
            logs = await asyncio.to_thread(container.logs, stdout=True, stderr=True)
            output = logs.decode("utf-8", errors="replace")

            # Parse stdout/stderr (simplified - both in output)
            return {
                "stdout": output,
                "stderr": "",
                "exit_code": result["StatusCode"],
            }

        except TimeoutError:
            # Kill container on timeout
            import contextlib

            with contextlib.suppress(Exception):
                await asyncio.to_thread(container.kill)
            raise

        finally:
            # Cleanup container
            import contextlib

            with contextlib.suppress(Exception):
                await asyncio.to_thread(container.remove, force=True)

__init__(docker_manager, runtime='runsc', max_memory_mb=512, max_cpu_cores=0.5, max_disk_mb=1024, max_execution_time=30, max_output_bytes=1048576)

Initialize sandbox manager.

Parameters:

Name Type Description Default
docker_manager DockerManager

Docker manager instance

required
runtime str

Container runtime (default: runsc for gVisor)

'runsc'
max_memory_mb int

Maximum memory per sandbox (MB)

512
max_cpu_cores float

Maximum CPU cores per sandbox

0.5
max_disk_mb int

Maximum disk space per sandbox (MB)

1024
max_execution_time int

Maximum execution time per run (seconds)

30
max_output_bytes int

Maximum output size (bytes)

1048576
Source code in src/harombe/security/sandbox_manager.py
def __init__(
    self,
    docker_manager: DockerManager,
    runtime: str = "runsc",
    max_memory_mb: int = 512,
    max_cpu_cores: float = 0.5,
    max_disk_mb: int = 1024,
    max_execution_time: int = 30,
    max_output_bytes: int = 1_048_576,
):
    """Initialize sandbox manager.

    Args:
        docker_manager: Docker manager instance
        runtime: Container runtime (default: runsc for gVisor)
        max_memory_mb: Maximum memory per sandbox (MB)
        max_cpu_cores: Maximum CPU cores per sandbox
        max_disk_mb: Maximum disk space per sandbox (MB)
        max_execution_time: Maximum execution time per run (seconds)
        max_output_bytes: Maximum output size (bytes)
    """
    self.docker_manager = docker_manager
    self.runtime = runtime
    self.max_memory_mb = max_memory_mb
    self.max_cpu_cores = max_cpu_cores
    self.max_disk_mb = max_disk_mb
    self.max_execution_time = max_execution_time
    self.max_output_bytes = max_output_bytes

    # Active sandboxes
    self._sandboxes: dict[str, Sandbox] = {}

    # Language-specific images
    self._images = {
        "python": "python:3.11-slim",
        "javascript": "node:20-slim",
        "shell": "bash:5.2",
    }

start() async

Start the sandbox manager.

Source code in src/harombe/security/sandbox_manager.py
async def start(self) -> None:
    """Start the sandbox manager."""
    await self.docker_manager.start()
    logger.info("SandboxManager started with runtime=%s", self.runtime)

stop() async

Stop the sandbox manager and cleanup all sandboxes.

Source code in src/harombe/security/sandbox_manager.py
async def stop(self) -> None:
    """Stop the sandbox manager and cleanup all sandboxes."""
    # Destroy all active sandboxes
    sandbox_ids = list(self._sandboxes.keys())
    for sandbox_id in sandbox_ids:
        try:
            await self.destroy_sandbox(sandbox_id)
        except Exception as e:
            logger.error(f"Error destroying sandbox {sandbox_id}: {e}")

    await self.docker_manager.stop()
    logger.info("SandboxManager stopped")

create_sandbox(language, sandbox_id=None, network_enabled=False, allowed_domains=None) async

Create a new gVisor sandbox.

Parameters:

Name Type Description Default
language str

Programming language (python, javascript, shell)

required
sandbox_id str | None

Optional sandbox ID (generated if not provided)

None
network_enabled bool

Enable network access

False
allowed_domains list[str] | None

Allowlisted domains (when network enabled)

None

Returns:

Type Description
str

Sandbox ID

Raises:

Type Description
ValueError

If language not supported or Docker not started

Source code in src/harombe/security/sandbox_manager.py
async def create_sandbox(
    self,
    language: str,
    sandbox_id: str | None = None,
    network_enabled: bool = False,
    allowed_domains: list[str] | None = None,
) -> str:
    """Create a new gVisor sandbox.

    Args:
        language: Programming language (python, javascript, shell)
        sandbox_id: Optional sandbox ID (generated if not provided)
        network_enabled: Enable network access
        allowed_domains: Allowlisted domains (when network enabled)

    Returns:
        Sandbox ID

    Raises:
        ValueError: If language not supported or Docker not started
    """
    if language not in self._images:
        raise ValueError(
            f"Unsupported language: {language}. " f"Supported: {list(self._images.keys())}"
        )

    if not self.docker_manager.client:
        raise RuntimeError("Docker manager not started")

    # Generate sandbox ID
    if sandbox_id is None:
        sandbox_id = f"sandbox-{uuid.uuid4().hex[:8]}"

    # Create temporary workspace
    workspace_path = f"/tmp/harombe-sandbox-{sandbox_id}"
    Path(workspace_path).mkdir(parents=True, exist_ok=True)

    # Create sandbox instance
    sandbox = Sandbox(
        sandbox_id=sandbox_id,
        language=language,
        network_enabled=network_enabled,
        allowed_domains=allowed_domains or [],
        workspace_path=workspace_path,
    )

    self._sandboxes[sandbox_id] = sandbox

    logger.info(
        f"Created sandbox {sandbox_id} for {language} " f"(network_enabled={network_enabled})"
    )

    return sandbox_id

execute_code(sandbox_id, code, timeout=None, max_memory_mb=None) async

Execute code in sandbox.

Parameters:

Name Type Description Default
sandbox_id str

Sandbox ID

required
code str

Code to execute

required
timeout int | None

Execution timeout (uses default if not provided)

None
max_memory_mb int | None

Memory limit (uses default if not provided)

None

Returns:

Type Description
ExecutionResult

Execution result with stdout, stderr, exit_code

Raises:

Type Description
ValueError

If sandbox not found

Source code in src/harombe/security/sandbox_manager.py
async def execute_code(
    self,
    sandbox_id: str,
    code: str,
    timeout: int | None = None,
    max_memory_mb: int | None = None,
) -> ExecutionResult:
    """Execute code in sandbox.

    Args:
        sandbox_id: Sandbox ID
        code: Code to execute
        timeout: Execution timeout (uses default if not provided)
        max_memory_mb: Memory limit (uses default if not provided)

    Returns:
        Execution result with stdout, stderr, exit_code

    Raises:
        ValueError: If sandbox not found
    """
    sandbox = self._get_sandbox(sandbox_id)

    timeout = timeout or self.max_execution_time
    max_memory_mb = max_memory_mb or self.max_memory_mb

    # Write code to workspace
    code_file = self._get_code_filename(sandbox.language)
    code_path = Path(sandbox.workspace_path) / code_file
    code_path.write_text(code)

    # Get execution command
    command = self._get_execution_command(sandbox.language, code_file)

    # Create container configuration
    container_config = {
        "image": self._images[sandbox.language],
        "runtime": self.runtime,
        "command": command,
        "network_mode": "none" if not sandbox.network_enabled else "bridge",
        "mem_limit": f"{max_memory_mb}m",
        "cpu_period": 100000,
        "cpu_quota": int(self.max_cpu_cores * 100000),
        "volumes": {
            sandbox.workspace_path: {
                "bind": "/workspace",
                "mode": "rw",
            }
        },
        "working_dir": "/workspace",
        "remove": True,
        "detach": False,
    }

    start_time = time.time()

    try:
        # Run container
        result = await self._run_container(container_config, timeout)

        execution_time = time.time() - start_time

        # Truncate output if too large
        stdout = result["stdout"][: self.max_output_bytes]
        stderr = result["stderr"][: self.max_output_bytes]

        if len(result["stdout"]) > self.max_output_bytes:
            stdout += "\n[OUTPUT TRUNCATED]"
        if len(result["stderr"]) > self.max_output_bytes:
            stderr += "\n[OUTPUT TRUNCATED]"

        sandbox.execution_count += 1

        logger.info(
            f"Executed code in sandbox {sandbox_id} "
            f"(exit_code={result['exit_code']}, time={execution_time:.2f}s)"
        )

        return ExecutionResult(
            success=result["exit_code"] == 0,
            stdout=stdout,
            stderr=stderr,
            exit_code=result["exit_code"],
            execution_time=execution_time,
        )

    except TimeoutError:
        execution_time = time.time() - start_time
        logger.warning(f"Code execution timeout in sandbox {sandbox_id}")
        return ExecutionResult(
            success=False,
            stdout="",
            stderr=f"Execution timeout after {timeout}s",
            exit_code=-1,
            execution_time=execution_time,
            error="TimeoutError",
        )
    except Exception as e:
        execution_time = time.time() - start_time
        logger.error(f"Code execution error in sandbox {sandbox_id}: {e}")
        return ExecutionResult(
            success=False,
            stdout="",
            stderr=str(e),
            exit_code=-1,
            execution_time=execution_time,
            error=type(e).__name__,
        )

install_package(sandbox_id, package, registry='pypi') async

Install package in sandbox.

Parameters:

Name Type Description Default
sandbox_id str

Sandbox ID

required
package str

Package name (with optional version)

required
registry str

Registry name (pypi, npm)

'pypi'

Returns:

Type Description
InstallResult

Installation result

Raises:

Type Description
ValueError

If sandbox not found or registry not supported

Source code in src/harombe/security/sandbox_manager.py
async def install_package(
    self,
    sandbox_id: str,
    package: str,
    registry: str = "pypi",
) -> InstallResult:
    """Install package in sandbox.

    Args:
        sandbox_id: Sandbox ID
        package: Package name (with optional version)
        registry: Registry name (pypi, npm)

    Returns:
        Installation result

    Raises:
        ValueError: If sandbox not found or registry not supported
    """
    sandbox = self._get_sandbox(sandbox_id)

    if not sandbox.network_enabled:
        return InstallResult(
            success=False,
            package=package,
            registry=registry,
            stdout="",
            stderr="Network access required for package installation",
            error="NetworkDisabled",
        )

    # Get install command
    install_cmd = self._get_install_command(sandbox.language, package, registry)

    if not install_cmd:
        return InstallResult(
            success=False,
            package=package,
            registry=registry,
            stdout="",
            stderr=f"Package installation not supported for {sandbox.language}",
            error="UnsupportedLanguage",
        )

    # Create container configuration
    container_config = {
        "image": self._images[sandbox.language],
        "runtime": self.runtime,
        "command": install_cmd,
        "network_mode": "bridge",  # Network required
        "mem_limit": f"{self.max_memory_mb}m",
        "volumes": {
            sandbox.workspace_path: {
                "bind": "/workspace",
                "mode": "rw",
            }
        },
        "working_dir": "/workspace",
        "remove": True,
        "detach": False,
    }

    try:
        result = await self._run_container(container_config, timeout=300)

        logger.info(
            f"Installed package {package} from {registry} "
            f"in sandbox {sandbox_id} (exit_code={result['exit_code']})"
        )

        return InstallResult(
            success=result["exit_code"] == 0,
            package=package,
            registry=registry,
            stdout=result["stdout"],
            stderr=result["stderr"],
        )

    except Exception as e:
        logger.error(f"Package installation error in sandbox {sandbox_id}: {e}")
        return InstallResult(
            success=False,
            package=package,
            registry=registry,
            stdout="",
            stderr=str(e),
            error=type(e).__name__,
        )

write_file(sandbox_id, file_path, content) async

Write file to sandbox workspace.

Parameters:

Name Type Description Default
sandbox_id str

Sandbox ID

required
file_path str

File path (relative to /workspace)

required
content str

File content

required

Returns:

Type Description
FileResult

Write result

Source code in src/harombe/security/sandbox_manager.py
async def write_file(
    self,
    sandbox_id: str,
    file_path: str,
    content: str,
) -> FileResult:
    """Write file to sandbox workspace.

    Args:
        sandbox_id: Sandbox ID
        file_path: File path (relative to /workspace)
        content: File content

    Returns:
        Write result
    """
    sandbox = self._get_sandbox(sandbox_id)

    try:
        # Ensure path is relative and within workspace
        clean_path = self._sanitize_path(file_path)
        full_path = Path(sandbox.workspace_path) / clean_path

        # Create parent directories
        full_path.parent.mkdir(parents=True, exist_ok=True)

        # Write file
        full_path.write_text(content)

        logger.info(f"Wrote file {file_path} in sandbox {sandbox_id}")

        return FileResult(
            success=True,
            path=file_path,
        )

    except Exception as e:
        logger.error(f"Write file error in sandbox {sandbox_id}: {e}")
        return FileResult(
            success=False,
            path=file_path,
            error=str(e),
        )

read_file(sandbox_id, file_path) async

Read file from sandbox workspace.

Parameters:

Name Type Description Default
sandbox_id str

Sandbox ID

required
file_path str

File path (relative to /workspace)

required

Returns:

Type Description
FileResult

Read result with file content

Source code in src/harombe/security/sandbox_manager.py
async def read_file(
    self,
    sandbox_id: str,
    file_path: str,
) -> FileResult:
    """Read file from sandbox workspace.

    Args:
        sandbox_id: Sandbox ID
        file_path: File path (relative to /workspace)

    Returns:
        Read result with file content
    """
    sandbox = self._get_sandbox(sandbox_id)

    try:
        # Ensure path is relative and within workspace
        clean_path = self._sanitize_path(file_path)
        full_path = Path(sandbox.workspace_path) / clean_path

        # Read file
        content = full_path.read_text()

        logger.info(f"Read file {file_path} from sandbox {sandbox_id}")

        return FileResult(
            success=True,
            path=file_path,
            content=content,
        )

    except FileNotFoundError:
        return FileResult(
            success=False,
            path=file_path,
            error=f"File not found: {file_path}",
        )
    except Exception as e:
        logger.error(f"Read file error in sandbox {sandbox_id}: {e}")
        return FileResult(
            success=False,
            path=file_path,
            error=str(e),
        )

list_files(sandbox_id, path='.') async

List files in sandbox workspace.

Parameters:

Name Type Description Default
sandbox_id str

Sandbox ID

required
path str

Directory path (relative to /workspace)

'.'

Returns:

Type Description
FileResult

List result with file names

Source code in src/harombe/security/sandbox_manager.py
async def list_files(
    self,
    sandbox_id: str,
    path: str = ".",
) -> FileResult:
    """List files in sandbox workspace.

    Args:
        sandbox_id: Sandbox ID
        path: Directory path (relative to /workspace)

    Returns:
        List result with file names
    """
    sandbox = self._get_sandbox(sandbox_id)

    try:
        # Ensure path is relative and within workspace
        clean_path = self._sanitize_path(path)
        full_path = Path(sandbox.workspace_path) / clean_path

        # List files
        if full_path.is_dir():
            files = [str(p.relative_to(sandbox.workspace_path)) for p in full_path.iterdir()]
        else:
            return FileResult(
                success=False,
                path=path,
                error=f"Not a directory: {path}",
            )

        logger.info(f"Listed files in {path} from sandbox {sandbox_id}")

        return FileResult(
            success=True,
            path=path,
            files=sorted(files),
        )

    except Exception as e:
        logger.error(f"List files error in sandbox {sandbox_id}: {e}")
        return FileResult(
            success=False,
            path=path,
            error=str(e),
        )

destroy_sandbox(sandbox_id) async

Destroy sandbox and cleanup resources.

Parameters:

Name Type Description Default
sandbox_id str

Sandbox ID

required

Raises:

Type Description
ValueError

If sandbox not found

Source code in src/harombe/security/sandbox_manager.py
async def destroy_sandbox(self, sandbox_id: str) -> None:
    """Destroy sandbox and cleanup resources.

    Args:
        sandbox_id: Sandbox ID

    Raises:
        ValueError: If sandbox not found
    """
    sandbox = self._get_sandbox(sandbox_id)

    # Cleanup workspace
    try:
        import shutil

        if sandbox.workspace_path and Path(sandbox.workspace_path).exists():
            shutil.rmtree(sandbox.workspace_path)
    except Exception as e:
        logger.warning(f"Error cleaning workspace for {sandbox_id}: {e}")

    # Remove from active sandboxes
    del self._sandboxes[sandbox_id]

    logger.info(f"Destroyed sandbox {sandbox_id}")

SecretMatch

Bases: BaseModel

A detected secret in text.

Source code in src/harombe/security/secrets.py
class SecretMatch(BaseModel):
    """A detected secret in text."""

    type: SecretType
    value: str
    start: int
    end: int
    confidence: float = Field(ge=0.0, le=1.0)
    context: str | None = None  # Surrounding text for context

SecretScanner

Scans text for secrets and credentials.

Uses multiple detection methods: 1. Regex patterns for known secret formats 2. Entropy analysis for random-looking strings 3. Contextual clues (variable names, key-value pairs)

Source code in src/harombe/security/secrets.py
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
class SecretScanner:
    """Scans text for secrets and credentials.

    Uses multiple detection methods:
    1. Regex patterns for known secret formats
    2. Entropy analysis for random-looking strings
    3. Contextual clues (variable names, key-value pairs)
    """

    # Known secret patterns with high confidence
    PATTERNS: ClassVar[dict[SecretType, list[re.Pattern]]] = {
        SecretType.AWS_KEY: [
            re.compile(r"AKIA[0-9A-Z]{16}"),  # AWS Access Key ID
            re.compile(
                r"(?i)aws.{0,20}?(?:key|secret|token).{0,20}?['\"]([A-Za-z0-9/+=]{40})['\"]"
            ),
        ],
        SecretType.AZURE_KEY: [
            re.compile(r"(?i)azure.{0,20}?['\"]([a-z0-9]{32,})['\"]"),
        ],
        SecretType.GCP_KEY: [
            re.compile(r'"type": "service_account"'),  # GCP service account JSON
            re.compile(r"(?i)gcp.{0,20}?['\"]([A-Za-z0-9_-]{20,})['\"]"),
        ],
        SecretType.GITHUB_TOKEN: [
            re.compile(r"ghp_[a-zA-Z0-9]{36}"),  # GitHub Personal Access Token
            re.compile(r"gho_[a-zA-Z0-9]{36}"),  # GitHub OAuth token
            re.compile(r"ghu_[a-zA-Z0-9]{36}"),  # GitHub User-to-server token
            re.compile(r"ghs_[a-zA-Z0-9]{36}"),  # GitHub Server-to-server token
            re.compile(r"ghr_[a-zA-Z0-9]{36}"),  # GitHub Refresh token
        ],
        SecretType.SLACK_TOKEN: [
            re.compile(r"xox[baprs]-[0-9a-zA-Z]{10,72}"),  # Slack tokens
        ],
        SecretType.STRIPE_KEY: [
            re.compile(r"sk_live_[0-9a-zA-Z]{24,}"),  # Stripe secret key
            re.compile(r"sk_test_[0-9a-zA-Z]{24,}"),  # Stripe test secret key
            re.compile(r"rk_live_[0-9a-zA-Z]{24,}"),  # Stripe restricted key
        ],
        SecretType.PRIVATE_KEY: [
            re.compile(r"-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----"),
            re.compile(r"-----BEGIN PGP PRIVATE KEY BLOCK-----"),
        ],
        SecretType.JWT_TOKEN: [
            re.compile(r"eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+"),
        ],
        SecretType.DATABASE_URL: [
            re.compile(r"(?i)(postgresql|mysql|mongodb|redis)://[^:\s]+:[^@\s]+@[^\s]+"),
        ],
        SecretType.PASSWORD: [
            re.compile(r"(?i)(password|passwd|pwd)\s*[:=]\s*['\"]?([^'\"\s]{8,})['\"]?"),
        ],
        SecretType.API_KEY: [
            re.compile(
                r"(?i)(api[_-]?key|apikey|access[_-]?token)\s*[:=]\s*['\"]?([a-zA-Z0-9_\-]{20,})['\"]?"
            ),
        ],
    }

    # Prefixes that indicate secrets
    SECRET_PREFIXES: ClassVar[list[str]] = [
        "sk-",  # OpenAI, Anthropic, generic secret keys
        "sk_",
        "pk_",  # Public key (less sensitive but still flag)
        "ghp_",  # GitHub
        "gho_",
        "ghu_",
        "ghs_",
        "ghr_",
        "xoxb-",  # Slack
        "xoxa-",
        "xoxp-",
        "xoxr-",
        "xoxs-",
        "AKIA",  # AWS
        "ASIA",
    ]

    # Minimum entropy for high-confidence secret detection
    MIN_ENTROPY = 3.5  # bits per character

    def __init__(
        self,
        min_confidence: float = 0.7,
        min_length: int = 16,
        enable_entropy_detection: bool = True,
    ):
        """Initialize secret scanner.

        Args:
            min_confidence: Minimum confidence threshold (0.0-1.0)
            min_length: Minimum length for entropy-based detection
            enable_entropy_detection: Enable entropy analysis
        """
        self.min_confidence = min_confidence
        self.min_length = min_length
        self.enable_entropy_detection = enable_entropy_detection

    def scan(self, text: str) -> list[SecretMatch]:
        """Scan text for secrets.

        Args:
            text: Text to scan

        Returns:
            List of detected secrets
        """
        matches: list[SecretMatch] = []

        # 1. Pattern-based detection (high confidence)
        matches.extend(self._scan_patterns(text))

        # 2. Prefix-based detection (medium confidence)
        matches.extend(self._scan_prefixes(text))

        # 3. Entropy-based detection (lower confidence, optional)
        if self.enable_entropy_detection:
            matches.extend(self._scan_entropy(text))

        # Deduplicate overlapping matches (keep highest confidence)
        matches = self._deduplicate_matches(matches)

        # Filter by confidence threshold
        matches = [m for m in matches if m.confidence >= self.min_confidence]

        return matches

    def _scan_patterns(self, text: str) -> list[SecretMatch]:
        """Scan using regex patterns.

        Args:
            text: Text to scan

        Returns:
            List of pattern matches
        """
        matches: list[SecretMatch] = []

        for secret_type, patterns in self.PATTERNS.items():
            for pattern in patterns:
                for match in pattern.finditer(text):
                    # Extract value (use first capture group if exists)
                    value = match.group(1) if match.groups() else match.group(0)

                    matches.append(
                        SecretMatch(
                            type=secret_type,
                            value=value,
                            start=match.start(),
                            end=match.end(),
                            confidence=0.95,  # High confidence for pattern matches
                            context=self._get_context(text, match.start(), match.end()),
                        )
                    )

        return matches

    def _scan_prefixes(self, text: str) -> list[SecretMatch]:
        """Scan for known secret prefixes.

        Args:
            text: Text to scan

        Returns:
            List of prefix matches
        """
        matches: list[SecretMatch] = []

        for prefix in self.SECRET_PREFIXES:
            # Find all occurrences of prefix
            start = 0
            while True:
                idx = text.find(prefix, start)
                if idx == -1:
                    break

                # Extract the full token (until whitespace or quote)
                end = idx + len(prefix)
                while end < len(text) and text[end] not in (
                    " ",
                    "\n",
                    "\t",
                    '"',
                    "'",
                    ",",
                    "}",
                    "]",
                ):
                    end += 1

                value = text[idx:end]

                # Only flag if long enough and has reasonable entropy
                if len(value) >= self.min_length:
                    entropy = self._calculate_entropy(value)
                    if entropy >= self.MIN_ENTROPY * 0.8:  # Slightly lower threshold
                        matches.append(
                            SecretMatch(
                                type=SecretType.GENERIC_SECRET,
                                value=value,
                                start=idx,
                                end=end,
                                confidence=0.85,  # Medium-high confidence
                                context=self._get_context(text, idx, end),
                            )
                        )

                start = end

        return matches

    def _scan_entropy(self, text: str) -> list[SecretMatch]:
        """Scan for high-entropy strings (potentially secrets).

        Args:
            text: Text to scan

        Returns:
            List of high-entropy matches
        """
        matches: list[SecretMatch] = []

        # Find all "words" (alphanumeric sequences)
        word_pattern = re.compile(r"[a-zA-Z0-9_\-+=/.]{16,}")

        for match in word_pattern.finditer(text):
            value = match.group(0)

            # Skip if too short
            if len(value) < self.min_length:
                continue

            # Calculate entropy
            entropy = self._calculate_entropy(value)

            # High entropy suggests randomness (potential secret)
            if entropy >= self.MIN_ENTROPY:
                # Check if it's in a suspicious context
                context = self._get_context(text, match.start(), match.end())
                confidence = self._calculate_confidence(value, context, entropy)

                if confidence >= self.min_confidence:
                    matches.append(
                        SecretMatch(
                            type=SecretType.GENERIC_SECRET,
                            value=value,
                            start=match.start(),
                            end=match.end(),
                            confidence=confidence,
                            context=context,
                        )
                    )

        return matches

    def _calculate_entropy(self, text: str) -> float:
        """Calculate Shannon entropy (bits per character).

        Args:
            text: Text to analyze

        Returns:
            Entropy in bits per character
        """
        if not text:
            return 0.0

        # Count character frequencies
        counter = Counter(text)
        length = len(text)

        # Calculate Shannon entropy
        entropy = 0.0
        for count in counter.values():
            probability = count / length
            entropy -= probability * math.log2(probability)

        return entropy

    def _calculate_confidence(
        self,
        value: str,
        context: str | None,
        entropy: float,
    ) -> float:
        """Calculate confidence score for potential secret.

        Args:
            value: The potential secret value
            context: Surrounding text
            entropy: Entropy of the value

        Returns:
            Confidence score 0.0-1.0
        """
        confidence = 0.5  # Base confidence for high-entropy string

        # Boost confidence based on entropy
        if entropy >= self.MIN_ENTROPY + 1.0:
            confidence += 0.2
        elif entropy >= self.MIN_ENTROPY + 0.5:
            confidence += 0.1

        # Boost if in suspicious context
        if context:
            suspicious_keywords = [
                "key",
                "token",
                "secret",
                "password",
                "credential",
                "auth",
                "api",
            ]
            context_lower = context.lower()
            for keyword in suspicious_keywords:
                if keyword in context_lower:
                    confidence += 0.15
                    break

        # Reduce confidence for common patterns that aren't secrets
        common_patterns = [
            r"^[0-9a-f]{32,}$",  # Hex hashes (MD5, SHA)
            r"^[A-Za-z0-9+/]{40,}={0,2}$",  # Base64 (but could be secret)
        ]
        for pattern in common_patterns:
            if re.match(pattern, value):
                confidence -= 0.1
                break

        return max(0.0, min(1.0, confidence))

    def _get_context(self, text: str, start: int, end: int, window: int = 30) -> str:
        """Get surrounding context for a match.

        Args:
            text: Full text
            start: Match start index
            end: Match end index
            window: Context window size (characters before/after)

        Returns:
            Context string
        """
        context_start = max(0, start - window)
        context_end = min(len(text), end + window)
        return text[context_start:context_end]

    def _deduplicate_matches(self, matches: list[SecretMatch]) -> list[SecretMatch]:
        """Remove overlapping matches, keeping highest confidence.

        Args:
            matches: List of matches

        Returns:
            Deduplicated list
        """
        if not matches:
            return []

        # Sort by start position
        sorted_matches = sorted(matches, key=lambda m: m.start)

        result: list[SecretMatch] = []
        current = sorted_matches[0]

        for match in sorted_matches[1:]:
            # Check for overlap
            if match.start < current.end:
                # Keep higher confidence match
                if match.confidence > current.confidence:
                    current = match
            else:
                result.append(current)
                current = match

        result.append(current)
        return result

    def redact(self, text: str, replacement: str = "[REDACTED]") -> str:
        """Scan and redact secrets from text.

        Args:
            text: Text to redact
            replacement: Replacement string for secrets

        Returns:
            Redacted text
        """
        matches = self.scan(text)

        # Redact from end to start to maintain indices
        result = text
        for match in sorted(matches, key=lambda m: m.start, reverse=True):
            result = result[: match.start] + replacement + result[match.end :]

        return result

    def alert_if_leaked(
        self,
        text: str,
        source: str = "unknown",
    ) -> list[SecretMatch]:
        """Scan text and return alerts for any secrets found.

        Args:
            text: Text to scan
            source: Source identifier for logging

        Returns:
            List of detected secrets (empty if none found)
        """
        matches = self.scan(text)

        if matches:
            # Log alert (in production, send to security monitoring)
            print(f"[SECURITY ALERT] Potential credential leakage in {source}:")
            for match in matches:
                print(f"  - Type: {match.type.value}, Confidence: {match.confidence:.2f}")
                if match.context:
                    print(f"    Context: ...{match.context}...")

        return matches

__init__(min_confidence=0.7, min_length=16, enable_entropy_detection=True)

Initialize secret scanner.

Parameters:

Name Type Description Default
min_confidence float

Minimum confidence threshold (0.0-1.0)

0.7
min_length int

Minimum length for entropy-based detection

16
enable_entropy_detection bool

Enable entropy analysis

True
Source code in src/harombe/security/secrets.py
def __init__(
    self,
    min_confidence: float = 0.7,
    min_length: int = 16,
    enable_entropy_detection: bool = True,
):
    """Initialize secret scanner.

    Args:
        min_confidence: Minimum confidence threshold (0.0-1.0)
        min_length: Minimum length for entropy-based detection
        enable_entropy_detection: Enable entropy analysis
    """
    self.min_confidence = min_confidence
    self.min_length = min_length
    self.enable_entropy_detection = enable_entropy_detection

scan(text)

Scan text for secrets.

Parameters:

Name Type Description Default
text str

Text to scan

required

Returns:

Type Description
list[SecretMatch]

List of detected secrets

Source code in src/harombe/security/secrets.py
def scan(self, text: str) -> list[SecretMatch]:
    """Scan text for secrets.

    Args:
        text: Text to scan

    Returns:
        List of detected secrets
    """
    matches: list[SecretMatch] = []

    # 1. Pattern-based detection (high confidence)
    matches.extend(self._scan_patterns(text))

    # 2. Prefix-based detection (medium confidence)
    matches.extend(self._scan_prefixes(text))

    # 3. Entropy-based detection (lower confidence, optional)
    if self.enable_entropy_detection:
        matches.extend(self._scan_entropy(text))

    # Deduplicate overlapping matches (keep highest confidence)
    matches = self._deduplicate_matches(matches)

    # Filter by confidence threshold
    matches = [m for m in matches if m.confidence >= self.min_confidence]

    return matches

redact(text, replacement='[REDACTED]')

Scan and redact secrets from text.

Parameters:

Name Type Description Default
text str

Text to redact

required
replacement str

Replacement string for secrets

'[REDACTED]'

Returns:

Type Description
str

Redacted text

Source code in src/harombe/security/secrets.py
def redact(self, text: str, replacement: str = "[REDACTED]") -> str:
    """Scan and redact secrets from text.

    Args:
        text: Text to redact
        replacement: Replacement string for secrets

    Returns:
        Redacted text
    """
    matches = self.scan(text)

    # Redact from end to start to maintain indices
    result = text
    for match in sorted(matches, key=lambda m: m.start, reverse=True):
        result = result[: match.start] + replacement + result[match.end :]

    return result

alert_if_leaked(text, source='unknown')

Scan text and return alerts for any secrets found.

Parameters:

Name Type Description Default
text str

Text to scan

required
source str

Source identifier for logging

'unknown'

Returns:

Type Description
list[SecretMatch]

List of detected secrets (empty if none found)

Source code in src/harombe/security/secrets.py
def alert_if_leaked(
    self,
    text: str,
    source: str = "unknown",
) -> list[SecretMatch]:
    """Scan text and return alerts for any secrets found.

    Args:
        text: Text to scan
        source: Source identifier for logging

    Returns:
        List of detected secrets (empty if none found)
    """
    matches = self.scan(text)

    if matches:
        # Log alert (in production, send to security monitoring)
        print(f"[SECURITY ALERT] Potential credential leakage in {source}:")
        for match in matches:
            print(f"  - Type: {match.type.value}, Confidence: {match.confidence:.2f}")
            if match.context:
                print(f"    Context: ...{match.context}...")

    return matches

SecretType

Bases: StrEnum

Types of secrets that can be detected.

Source code in src/harombe/security/secrets.py
class SecretType(StrEnum):
    """Types of secrets that can be detected."""

    API_KEY = "api_key"
    AWS_KEY = "aws_key"
    AZURE_KEY = "azure_key"
    GCP_KEY = "gcp_key"
    GITHUB_TOKEN = "github_token"
    SLACK_TOKEN = "slack_token"
    STRIPE_KEY = "stripe_key"
    PASSWORD = "password"
    PRIVATE_KEY = "private_key"
    JWT_TOKEN = "jwt_token"
    OAUTH_TOKEN = "oauth_token"
    DATABASE_URL = "database_url"
    GENERIC_SECRET = "generic_secret"

DatadogExporter

Bases: SIEMExporter

Export events to Datadog Logs API.

Source code in src/harombe/security/siem_integration.py
class DatadogExporter(SIEMExporter):
    """Export events to Datadog Logs API."""

    def format_events(self, events: list[SIEMEvent]) -> Any:
        """Format events for Datadog Logs API."""
        return [
            {
                **event.model_dump(mode="json", exclude={"source", "status", "severity"}),
                "ddsource": "harombe",
                "ddtags": f"env:production,service:harombe,type:{event.event_type}",
                "hostname": "harombe-gateway",
                "message": f"{event.action}: {event.status}",
                "service": "harombe-security",
                "status": _severity_to_datadog_status(event.severity),
                "event_status": event.status,
            }
            for event in events
        ]

    def get_headers(self) -> dict[str, str]:
        return {
            "DD-API-KEY": self.config.token,
            "Content-Type": "application/json",
        }

    def get_url(self) -> str:
        endpoint = self.config.endpoint.rstrip("/")
        return f"{endpoint}/api/v2/logs"

format_events(events)

Format events for Datadog Logs API.

Source code in src/harombe/security/siem_integration.py
def format_events(self, events: list[SIEMEvent]) -> Any:
    """Format events for Datadog Logs API."""
    return [
        {
            **event.model_dump(mode="json", exclude={"source", "status", "severity"}),
            "ddsource": "harombe",
            "ddtags": f"env:production,service:harombe,type:{event.event_type}",
            "hostname": "harombe-gateway",
            "message": f"{event.action}: {event.status}",
            "service": "harombe-security",
            "status": _severity_to_datadog_status(event.severity),
            "event_status": event.status,
        }
        for event in events
    ]

ElasticsearchExporter

Bases: SIEMExporter

Export events to Elasticsearch (ELK Stack).

Source code in src/harombe/security/siem_integration.py
class ElasticsearchExporter(SIEMExporter):
    """Export events to Elasticsearch (ELK Stack)."""

    def format_events(self, events: list[SIEMEvent]) -> Any:
        """Format events for Elasticsearch bulk API."""
        # Elasticsearch bulk API expects action/source pairs in NDJSON
        # For simplicity, we send as a batch document array
        return [
            {
                "_index": self.config.index,
                "_id": event.event_id,
                "_source": event.model_dump(mode="json"),
            }
            for event in events
        ]

    def get_headers(self) -> dict[str, str]:
        headers: dict[str, str] = {"Content-Type": "application/json"}
        if self.config.token:
            headers["Authorization"] = f"ApiKey {self.config.token}"
        return headers

    def get_url(self) -> str:
        endpoint = self.config.endpoint.rstrip("/")
        return f"{endpoint}/{self.config.index}/_bulk"

format_events(events)

Format events for Elasticsearch bulk API.

Source code in src/harombe/security/siem_integration.py
def format_events(self, events: list[SIEMEvent]) -> Any:
    """Format events for Elasticsearch bulk API."""
    # Elasticsearch bulk API expects action/source pairs in NDJSON
    # For simplicity, we send as a batch document array
    return [
        {
            "_index": self.config.index,
            "_id": event.event_id,
            "_source": event.model_dump(mode="json"),
        }
        for event in events
    ]

ExportResult

Bases: BaseModel

Result of a SIEM export operation.

Source code in src/harombe/security/siem_integration.py
class ExportResult(BaseModel):
    """Result of a SIEM export operation."""

    success: bool
    platform: SIEMPlatform
    events_sent: int = 0
    events_failed: int = 0
    latency_ms: float = 0.0
    error: str | None = None
    retries: int = 0

SIEMConfig

Bases: BaseModel

Configuration for a SIEM exporter.

Source code in src/harombe/security/siem_integration.py
class SIEMConfig(BaseModel):
    """Configuration for a SIEM exporter."""

    platform: SIEMPlatform
    endpoint: str  # Base URL
    token: str = ""  # Auth token
    index: str = "harombe-security"  # Index/source name
    enabled: bool = True
    batch_size: int = Field(default=50, ge=1, le=1000)
    flush_interval_s: float = Field(default=5.0, ge=0.1, le=300.0)
    max_retries: int = Field(default=3, ge=0, le=10)
    retry_delay_s: float = Field(default=1.0, ge=0.01, le=60.0)
    timeout_s: float = Field(default=10.0, ge=1.0, le=120.0)

SIEMEvent

Bases: BaseModel

Normalized event for SIEM export.

Source code in src/harombe/security/siem_integration.py
class SIEMEvent(BaseModel):
    """Normalized event for SIEM export."""

    event_id: str
    timestamp: str  # ISO 8601
    event_type: str
    actor: str
    action: str
    tool_name: str | None = None
    status: str
    correlation_id: str
    session_id: str | None = None
    duration_ms: int | None = None
    error_message: str | None = None
    metadata: dict[str, Any] = Field(default_factory=dict)
    source: str = "harombe"
    severity: str = "info"

    @classmethod
    def from_audit_event(cls, event: AuditEvent) -> "SIEMEvent":
        """Convert an AuditEvent to a normalized SIEMEvent."""
        severity = "info"
        if event.event_type == EventType.ERROR:
            severity = "error"
        elif event.event_type == EventType.SECURITY_DECISION:
            severity = "warning"
        elif event.status == "error":
            severity = "error"

        return cls(
            event_id=event.event_id,
            timestamp=event.timestamp.isoformat() + "Z",
            event_type=event.event_type.value,
            actor=event.actor,
            action=event.action,
            tool_name=event.tool_name,
            status=event.status,
            correlation_id=event.correlation_id,
            session_id=event.session_id,
            duration_ms=event.duration_ms,
            error_message=event.error_message,
            metadata=event.metadata,
            severity=severity,
        )

from_audit_event(event) classmethod

Convert an AuditEvent to a normalized SIEMEvent.

Source code in src/harombe/security/siem_integration.py
@classmethod
def from_audit_event(cls, event: AuditEvent) -> "SIEMEvent":
    """Convert an AuditEvent to a normalized SIEMEvent."""
    severity = "info"
    if event.event_type == EventType.ERROR:
        severity = "error"
    elif event.event_type == EventType.SECURITY_DECISION:
        severity = "warning"
    elif event.status == "error":
        severity = "error"

    return cls(
        event_id=event.event_id,
        timestamp=event.timestamp.isoformat() + "Z",
        event_type=event.event_type.value,
        actor=event.actor,
        action=event.action,
        tool_name=event.tool_name,
        status=event.status,
        correlation_id=event.correlation_id,
        session_id=event.session_id,
        duration_ms=event.duration_ms,
        error_message=event.error_message,
        metadata=event.metadata,
        severity=severity,
    )

SIEMExporter

Base class for SIEM exporters.

Handles format conversion and HTTP transport for a single platform.

Source code in src/harombe/security/siem_integration.py
class SIEMExporter:
    """Base class for SIEM exporters.

    Handles format conversion and HTTP transport for a single platform.
    """

    def __init__(self, config: SIEMConfig):
        self.config = config
        self._client: httpx.AsyncClient | None = None

    async def _get_client(self) -> httpx.AsyncClient:
        """Get or create HTTP client."""
        if self._client is None or self._client.is_closed:
            self._client = httpx.AsyncClient(
                timeout=httpx.Timeout(self.config.timeout_s),
            )
        return self._client

    async def close(self) -> None:
        """Close the HTTP client."""
        if self._client and not self._client.is_closed:
            await self._client.aclose()
            self._client = None

    def format_events(self, events: list[SIEMEvent]) -> Any:
        """Format events for the specific SIEM platform.

        Must be overridden by subclasses.
        """
        raise NotImplementedError

    def get_headers(self) -> dict[str, str]:
        """Get HTTP headers for the SIEM platform.

        Must be overridden by subclasses.
        """
        raise NotImplementedError

    def get_url(self) -> str:
        """Get the endpoint URL for sending events.

        Must be overridden by subclasses.
        """
        raise NotImplementedError

    async def send(self, events: list[SIEMEvent]) -> ExportResult:
        """Send events to the SIEM platform with retry logic."""
        if not events:
            return ExportResult(
                success=True,
                platform=self.config.platform,
                events_sent=0,
            )

        start = time.perf_counter()
        retries = 0
        last_error = None

        for attempt in range(self.config.max_retries + 1):
            try:
                client = await self._get_client()
                payload = self.format_events(events)
                headers = self.get_headers()
                url = self.get_url()

                response = await client.post(url, headers=headers, json=payload)
                response.raise_for_status()

                elapsed_ms = (time.perf_counter() - start) * 1000
                return ExportResult(
                    success=True,
                    platform=self.config.platform,
                    events_sent=len(events),
                    latency_ms=elapsed_ms,
                    retries=retries,
                )

            except (httpx.HTTPStatusError, httpx.RequestError, httpx.TimeoutException) as e:
                last_error = str(e)
                retries = attempt + 1
                if attempt < self.config.max_retries:
                    delay = self.config.retry_delay_s * (2**attempt)
                    await asyncio.sleep(delay)

        elapsed_ms = (time.perf_counter() - start) * 1000
        return ExportResult(
            success=False,
            platform=self.config.platform,
            events_failed=len(events),
            latency_ms=elapsed_ms,
            error=last_error,
            retries=retries,
        )

close() async

Close the HTTP client.

Source code in src/harombe/security/siem_integration.py
async def close(self) -> None:
    """Close the HTTP client."""
    if self._client and not self._client.is_closed:
        await self._client.aclose()
        self._client = None

format_events(events)

Format events for the specific SIEM platform.

Must be overridden by subclasses.

Source code in src/harombe/security/siem_integration.py
def format_events(self, events: list[SIEMEvent]) -> Any:
    """Format events for the specific SIEM platform.

    Must be overridden by subclasses.
    """
    raise NotImplementedError

get_headers()

Get HTTP headers for the SIEM platform.

Must be overridden by subclasses.

Source code in src/harombe/security/siem_integration.py
def get_headers(self) -> dict[str, str]:
    """Get HTTP headers for the SIEM platform.

    Must be overridden by subclasses.
    """
    raise NotImplementedError

get_url()

Get the endpoint URL for sending events.

Must be overridden by subclasses.

Source code in src/harombe/security/siem_integration.py
def get_url(self) -> str:
    """Get the endpoint URL for sending events.

    Must be overridden by subclasses.
    """
    raise NotImplementedError

send(events) async

Send events to the SIEM platform with retry logic.

Source code in src/harombe/security/siem_integration.py
async def send(self, events: list[SIEMEvent]) -> ExportResult:
    """Send events to the SIEM platform with retry logic."""
    if not events:
        return ExportResult(
            success=True,
            platform=self.config.platform,
            events_sent=0,
        )

    start = time.perf_counter()
    retries = 0
    last_error = None

    for attempt in range(self.config.max_retries + 1):
        try:
            client = await self._get_client()
            payload = self.format_events(events)
            headers = self.get_headers()
            url = self.get_url()

            response = await client.post(url, headers=headers, json=payload)
            response.raise_for_status()

            elapsed_ms = (time.perf_counter() - start) * 1000
            return ExportResult(
                success=True,
                platform=self.config.platform,
                events_sent=len(events),
                latency_ms=elapsed_ms,
                retries=retries,
            )

        except (httpx.HTTPStatusError, httpx.RequestError, httpx.TimeoutException) as e:
            last_error = str(e)
            retries = attempt + 1
            if attempt < self.config.max_retries:
                delay = self.config.retry_delay_s * (2**attempt)
                await asyncio.sleep(delay)

    elapsed_ms = (time.perf_counter() - start) * 1000
    return ExportResult(
        success=False,
        platform=self.config.platform,
        events_failed=len(events),
        latency_ms=elapsed_ms,
        error=last_error,
        retries=retries,
    )

SIEMIntegrator

Orchestrates event forwarding to multiple SIEM platforms.

Provides buffered, batched event export with automatic flushing and retry logic for handling SIEM downtime.

Usage

configs = [ SIEMConfig(platform="splunk", endpoint="https://splunk.example.com:8088", token="my-hec-token"), SIEMConfig(platform="elasticsearch", endpoint="https://elk.example.com:9200"), ] integrator = SIEMIntegrator(configs) await integrator.start()

Export events

await integrator.export_event(audit_event)

Shutdown

await integrator.stop()

Source code in src/harombe/security/siem_integration.py
class SIEMIntegrator:
    """Orchestrates event forwarding to multiple SIEM platforms.

    Provides buffered, batched event export with automatic flushing
    and retry logic for handling SIEM downtime.

    Usage:
        configs = [
            SIEMConfig(platform="splunk", endpoint="https://splunk.example.com:8088",
                       token="my-hec-token"),
            SIEMConfig(platform="elasticsearch", endpoint="https://elk.example.com:9200"),
        ]
        integrator = SIEMIntegrator(configs)
        await integrator.start()

        # Export events
        await integrator.export_event(audit_event)

        # Shutdown
        await integrator.stop()
    """

    def __init__(self, configs: list[SIEMConfig] | None = None):
        """Initialize SIEM integrator.

        Args:
            configs: List of SIEM configurations. Only enabled configs are used.
        """
        self._configs = configs or []
        self._exporters: dict[SIEMPlatform, SIEMExporter] = {}
        self._buffers: dict[SIEMPlatform, list[SIEMEvent]] = {}
        self._flush_task: asyncio.Task | None = None
        self._running = False
        self._lock = asyncio.Lock()

        # Statistics
        self.stats: dict[str, Any] = {
            "events_received": 0,
            "events_exported": 0,
            "events_failed": 0,
            "exports_total": 0,
            "exports_successful": 0,
            "exports_failed": 0,
            "total_retries": 0,
            "avg_latency_ms": 0.0,
            "per_platform": {},
        }

        # Initialize exporters for enabled configs
        for config in self._configs:
            if config.enabled:
                self._exporters[config.platform] = _create_exporter(config)
                self._buffers[config.platform] = []
                self.stats["per_platform"][config.platform.value] = {
                    "events_exported": 0,
                    "events_failed": 0,
                    "exports_total": 0,
                    "exports_successful": 0,
                    "exports_failed": 0,
                    "avg_latency_ms": 0.0,
                }

    @property
    def platforms(self) -> list[SIEMPlatform]:
        """Get list of configured platforms."""
        return list(self._exporters.keys())

    async def start(self) -> None:
        """Start the background flush worker."""
        if self._running:
            return
        self._running = True
        if self._exporters:
            self._flush_task = asyncio.create_task(self._flush_worker())

    async def stop(self) -> None:
        """Stop the integrator and flush remaining events."""
        self._running = False
        # Flush any remaining buffered events
        await self.flush_all()
        # Cancel flush worker
        if self._flush_task and not self._flush_task.done():
            self._flush_task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await self._flush_task
        # Close all exporters
        for exporter in self._exporters.values():
            await exporter.close()

    async def export_event(self, event: AuditEvent) -> None:
        """Buffer an audit event for export to all configured SIEMs.

        Args:
            event: Audit event to export
        """
        self.stats["events_received"] += 1
        siem_event = SIEMEvent.from_audit_event(event)

        async with self._lock:
            for platform in self._exporters:
                self._buffers[platform].append(siem_event)

                # Check if buffer is full → flush immediately
                config = self._get_config(platform)
                if config and len(self._buffers[platform]) >= config.batch_size:
                    await self._flush_platform(platform)

    async def export_events(self, events: list[AuditEvent]) -> list[ExportResult]:
        """Export multiple events at once.

        Args:
            events: List of audit events to export

        Returns:
            List of export results per platform
        """
        for event in events:
            self.stats["events_received"] += 1
            siem_event = SIEMEvent.from_audit_event(event)
            async with self._lock:
                for platform in self._exporters:
                    self._buffers[platform].append(siem_event)

        return await self.flush_all()

    async def flush_all(self) -> list[ExportResult]:
        """Flush all buffered events to all platforms.

        Returns:
            List of export results per platform
        """
        results = []
        async with self._lock:
            for platform in self._exporters:
                if self._buffers[platform]:
                    result = await self._flush_platform(platform)
                    results.append(result)
        return results

    async def _flush_platform(self, platform: SIEMPlatform) -> ExportResult:
        """Flush buffered events for a specific platform.

        Must be called with self._lock held.
        """
        events = self._buffers[platform]
        self._buffers[platform] = []

        if not events:
            return ExportResult(
                success=True,
                platform=platform,
                events_sent=0,
            )

        exporter = self._exporters[platform]
        result = await exporter.send(events)

        # Update stats
        platform_key = platform.value
        self.stats["exports_total"] += 1
        self.stats["per_platform"][platform_key]["exports_total"] += 1
        self.stats["total_retries"] += result.retries

        if result.success:
            self.stats["events_exported"] += result.events_sent
            self.stats["exports_successful"] += 1
            self.stats["per_platform"][platform_key]["events_exported"] += result.events_sent
            self.stats["per_platform"][platform_key]["exports_successful"] += 1
        else:
            self.stats["events_failed"] += result.events_failed
            self.stats["exports_failed"] += 1
            self.stats["per_platform"][platform_key]["events_failed"] += result.events_failed
            self.stats["per_platform"][platform_key]["exports_failed"] += 1

        # Update average latency (running average)
        total_exports = self.stats["exports_total"]
        if total_exports > 0:
            prev_avg = self.stats["avg_latency_ms"]
            self.stats["avg_latency_ms"] = prev_avg + (result.latency_ms - prev_avg) / total_exports

        platform_exports = self.stats["per_platform"][platform_key]["exports_total"]
        if platform_exports > 0:
            prev_avg = self.stats["per_platform"][platform_key]["avg_latency_ms"]
            self.stats["per_platform"][platform_key]["avg_latency_ms"] = (
                prev_avg + (result.latency_ms - prev_avg) / platform_exports
            )

        return result

    async def _flush_worker(self) -> None:
        """Background worker that periodically flushes buffers."""
        # Use the minimum flush interval across all configs
        min_interval = min(
            (c.flush_interval_s for c in self._configs if c.enabled),
            default=5.0,
        )
        while self._running:
            try:
                await asyncio.sleep(min_interval)
                if self._running:
                    await self.flush_all()
            except asyncio.CancelledError:
                break
            except Exception:
                pass  # Don't crash the worker

    def _get_config(self, platform: SIEMPlatform) -> SIEMConfig | None:
        """Get the config for a platform."""
        for config in self._configs:
            if config.platform == platform:
                return config
        return None

    def get_stats(self) -> dict[str, Any]:
        """Get export statistics."""
        return dict(self.stats)

    def add_platform(self, config: SIEMConfig) -> None:
        """Add a new SIEM platform at runtime.

        Args:
            config: SIEM configuration to add
        """
        if not config.enabled:
            return
        self._configs.append(config)
        self._exporters[config.platform] = _create_exporter(config)
        self._buffers[config.platform] = []
        self.stats["per_platform"][config.platform.value] = {
            "events_exported": 0,
            "events_failed": 0,
            "exports_total": 0,
            "exports_successful": 0,
            "exports_failed": 0,
            "avg_latency_ms": 0.0,
        }

    def remove_platform(self, platform: SIEMPlatform) -> None:
        """Remove a SIEM platform.

        Args:
            platform: Platform to remove
        """
        self._exporters.pop(platform, None)
        self._buffers.pop(platform, None)
        self._configs = [c for c in self._configs if c.platform != platform]

platforms property

Get list of configured platforms.

__init__(configs=None)

Initialize SIEM integrator.

Parameters:

Name Type Description Default
configs list[SIEMConfig] | None

List of SIEM configurations. Only enabled configs are used.

None
Source code in src/harombe/security/siem_integration.py
def __init__(self, configs: list[SIEMConfig] | None = None):
    """Initialize SIEM integrator.

    Args:
        configs: List of SIEM configurations. Only enabled configs are used.
    """
    self._configs = configs or []
    self._exporters: dict[SIEMPlatform, SIEMExporter] = {}
    self._buffers: dict[SIEMPlatform, list[SIEMEvent]] = {}
    self._flush_task: asyncio.Task | None = None
    self._running = False
    self._lock = asyncio.Lock()

    # Statistics
    self.stats: dict[str, Any] = {
        "events_received": 0,
        "events_exported": 0,
        "events_failed": 0,
        "exports_total": 0,
        "exports_successful": 0,
        "exports_failed": 0,
        "total_retries": 0,
        "avg_latency_ms": 0.0,
        "per_platform": {},
    }

    # Initialize exporters for enabled configs
    for config in self._configs:
        if config.enabled:
            self._exporters[config.platform] = _create_exporter(config)
            self._buffers[config.platform] = []
            self.stats["per_platform"][config.platform.value] = {
                "events_exported": 0,
                "events_failed": 0,
                "exports_total": 0,
                "exports_successful": 0,
                "exports_failed": 0,
                "avg_latency_ms": 0.0,
            }

start() async

Start the background flush worker.

Source code in src/harombe/security/siem_integration.py
async def start(self) -> None:
    """Start the background flush worker."""
    if self._running:
        return
    self._running = True
    if self._exporters:
        self._flush_task = asyncio.create_task(self._flush_worker())

stop() async

Stop the integrator and flush remaining events.

Source code in src/harombe/security/siem_integration.py
async def stop(self) -> None:
    """Stop the integrator and flush remaining events."""
    self._running = False
    # Flush any remaining buffered events
    await self.flush_all()
    # Cancel flush worker
    if self._flush_task and not self._flush_task.done():
        self._flush_task.cancel()
        with contextlib.suppress(asyncio.CancelledError):
            await self._flush_task
    # Close all exporters
    for exporter in self._exporters.values():
        await exporter.close()

export_event(event) async

Buffer an audit event for export to all configured SIEMs.

Parameters:

Name Type Description Default
event AuditEvent

Audit event to export

required
Source code in src/harombe/security/siem_integration.py
async def export_event(self, event: AuditEvent) -> None:
    """Buffer an audit event for export to all configured SIEMs.

    Args:
        event: Audit event to export
    """
    self.stats["events_received"] += 1
    siem_event = SIEMEvent.from_audit_event(event)

    async with self._lock:
        for platform in self._exporters:
            self._buffers[platform].append(siem_event)

            # Check if buffer is full → flush immediately
            config = self._get_config(platform)
            if config and len(self._buffers[platform]) >= config.batch_size:
                await self._flush_platform(platform)

export_events(events) async

Export multiple events at once.

Parameters:

Name Type Description Default
events list[AuditEvent]

List of audit events to export

required

Returns:

Type Description
list[ExportResult]

List of export results per platform

Source code in src/harombe/security/siem_integration.py
async def export_events(self, events: list[AuditEvent]) -> list[ExportResult]:
    """Export multiple events at once.

    Args:
        events: List of audit events to export

    Returns:
        List of export results per platform
    """
    for event in events:
        self.stats["events_received"] += 1
        siem_event = SIEMEvent.from_audit_event(event)
        async with self._lock:
            for platform in self._exporters:
                self._buffers[platform].append(siem_event)

    return await self.flush_all()

flush_all() async

Flush all buffered events to all platforms.

Returns:

Type Description
list[ExportResult]

List of export results per platform

Source code in src/harombe/security/siem_integration.py
async def flush_all(self) -> list[ExportResult]:
    """Flush all buffered events to all platforms.

    Returns:
        List of export results per platform
    """
    results = []
    async with self._lock:
        for platform in self._exporters:
            if self._buffers[platform]:
                result = await self._flush_platform(platform)
                results.append(result)
    return results

get_stats()

Get export statistics.

Source code in src/harombe/security/siem_integration.py
def get_stats(self) -> dict[str, Any]:
    """Get export statistics."""
    return dict(self.stats)

add_platform(config)

Add a new SIEM platform at runtime.

Parameters:

Name Type Description Default
config SIEMConfig

SIEM configuration to add

required
Source code in src/harombe/security/siem_integration.py
def add_platform(self, config: SIEMConfig) -> None:
    """Add a new SIEM platform at runtime.

    Args:
        config: SIEM configuration to add
    """
    if not config.enabled:
        return
    self._configs.append(config)
    self._exporters[config.platform] = _create_exporter(config)
    self._buffers[config.platform] = []
    self.stats["per_platform"][config.platform.value] = {
        "events_exported": 0,
        "events_failed": 0,
        "exports_total": 0,
        "exports_successful": 0,
        "exports_failed": 0,
        "avg_latency_ms": 0.0,
    }

remove_platform(platform)

Remove a SIEM platform.

Parameters:

Name Type Description Default
platform SIEMPlatform

Platform to remove

required
Source code in src/harombe/security/siem_integration.py
def remove_platform(self, platform: SIEMPlatform) -> None:
    """Remove a SIEM platform.

    Args:
        platform: Platform to remove
    """
    self._exporters.pop(platform, None)
    self._buffers.pop(platform, None)
    self._configs = [c for c in self._configs if c.platform != platform]

SIEMPlatform

Bases: StrEnum

Supported SIEM platforms.

Source code in src/harombe/security/siem_integration.py
class SIEMPlatform(StrEnum):
    """Supported SIEM platforms."""

    SPLUNK = "splunk"
    ELASTICSEARCH = "elasticsearch"
    DATADOG = "datadog"

SplunkExporter

Bases: SIEMExporter

Export events to Splunk via HTTP Event Collector (HEC).

Source code in src/harombe/security/siem_integration.py
class SplunkExporter(SIEMExporter):
    """Export events to Splunk via HTTP Event Collector (HEC)."""

    def format_events(self, events: list[SIEMEvent]) -> Any:
        """Format events for Splunk HEC batch endpoint."""
        # Splunk HEC expects individual event objects
        # For batch, we send a list
        return [
            {
                "event": event.model_dump(mode="json"),
                "sourcetype": "harombe:security",
                "source": "harombe",
                "index": self.config.index,
                "time": _iso_to_epoch(event.timestamp),
            }
            for event in events
        ]

    def get_headers(self) -> dict[str, str]:
        return {
            "Authorization": f"Splunk {self.config.token}",
            "Content-Type": "application/json",
        }

    def get_url(self) -> str:
        endpoint = self.config.endpoint.rstrip("/")
        return f"{endpoint}/services/collector/event"

format_events(events)

Format events for Splunk HEC batch endpoint.

Source code in src/harombe/security/siem_integration.py
def format_events(self, events: list[SIEMEvent]) -> Any:
    """Format events for Splunk HEC batch endpoint."""
    # Splunk HEC expects individual event objects
    # For batch, we send a list
    return [
        {
            "event": event.model_dump(mode="json"),
            "sourcetype": "harombe:security",
            "source": "harombe",
            "index": self.config.index,
            "time": _iso_to_epoch(event.timestamp),
        }
        for event in events
    ]

EnvVarBackend

Bases: VaultBackend

Environment variable backend for development.

NOT SECURE - only use for local development. Reads secrets from environment variables prefixed with HAROMBE_SECRET_.

Source code in src/harombe/security/vault.py
class EnvVarBackend(VaultBackend):
    """Environment variable backend for development.

    NOT SECURE - only use for local development.
    Reads secrets from environment variables prefixed with HAROMBE_SECRET_.
    """

    def __init__(self, prefix: str = "HAROMBE_SECRET_"):
        """Initialize environment variable backend.

        Args:
            prefix: Environment variable prefix
        """
        self.prefix = prefix

    async def get_secret(self, key: str) -> str | None:
        """Get secret from environment.

        Args:
            key: Secret key

        Returns:
            Secret value from env var
        """
        env_key = f"{self.prefix}{key.upper().replace('/', '_')}"
        return os.getenv(env_key)

    async def set_secret(self, key: str, value: str, **metadata: Any) -> None:
        """Set secret in environment (runtime only).

        Args:
            key: Secret key
            value: Secret value
            metadata: Ignored
        """
        env_key = f"{self.prefix}{key.upper().replace('/', '_')}"
        os.environ[env_key] = value

    async def delete_secret(self, key: str) -> None:
        """Delete secret from environment.

        Args:
            key: Secret key
        """
        env_key = f"{self.prefix}{key.upper().replace('/', '_')}"
        os.environ.pop(env_key, None)

    async def list_secrets(self, prefix: str = "") -> list[str]:
        """List secrets from environment.

        Args:
            prefix: Key prefix

        Returns:
            List of secret keys
        """
        keys = []
        env_prefix = f"{self.prefix}{prefix.upper().replace('/', '_')}"

        for env_key in os.environ:
            if env_key.startswith(env_prefix):
                # Convert back to key format
                key = env_key[len(self.prefix) :].lower().replace("_", "/")
                keys.append(key)

        return keys

    async def rotate_secret(self, key: str) -> None:
        """No-op for environment variables.

        Args:
            key: Secret key
        """
        pass

__init__(prefix='HAROMBE_SECRET_')

Initialize environment variable backend.

Parameters:

Name Type Description Default
prefix str

Environment variable prefix

'HAROMBE_SECRET_'
Source code in src/harombe/security/vault.py
def __init__(self, prefix: str = "HAROMBE_SECRET_"):
    """Initialize environment variable backend.

    Args:
        prefix: Environment variable prefix
    """
    self.prefix = prefix

get_secret(key) async

Get secret from environment.

Parameters:

Name Type Description Default
key str

Secret key

required

Returns:

Type Description
str | None

Secret value from env var

Source code in src/harombe/security/vault.py
async def get_secret(self, key: str) -> str | None:
    """Get secret from environment.

    Args:
        key: Secret key

    Returns:
        Secret value from env var
    """
    env_key = f"{self.prefix}{key.upper().replace('/', '_')}"
    return os.getenv(env_key)

set_secret(key, value, **metadata) async

Set secret in environment (runtime only).

Parameters:

Name Type Description Default
key str

Secret key

required
value str

Secret value

required
metadata Any

Ignored

{}
Source code in src/harombe/security/vault.py
async def set_secret(self, key: str, value: str, **metadata: Any) -> None:
    """Set secret in environment (runtime only).

    Args:
        key: Secret key
        value: Secret value
        metadata: Ignored
    """
    env_key = f"{self.prefix}{key.upper().replace('/', '_')}"
    os.environ[env_key] = value

delete_secret(key) async

Delete secret from environment.

Parameters:

Name Type Description Default
key str

Secret key

required
Source code in src/harombe/security/vault.py
async def delete_secret(self, key: str) -> None:
    """Delete secret from environment.

    Args:
        key: Secret key
    """
    env_key = f"{self.prefix}{key.upper().replace('/', '_')}"
    os.environ.pop(env_key, None)

list_secrets(prefix='') async

List secrets from environment.

Parameters:

Name Type Description Default
prefix str

Key prefix

''

Returns:

Type Description
list[str]

List of secret keys

Source code in src/harombe/security/vault.py
async def list_secrets(self, prefix: str = "") -> list[str]:
    """List secrets from environment.

    Args:
        prefix: Key prefix

    Returns:
        List of secret keys
    """
    keys = []
    env_prefix = f"{self.prefix}{prefix.upper().replace('/', '_')}"

    for env_key in os.environ:
        if env_key.startswith(env_prefix):
            # Convert back to key format
            key = env_key[len(self.prefix) :].lower().replace("_", "/")
            keys.append(key)

    return keys

rotate_secret(key) async

No-op for environment variables.

Parameters:

Name Type Description Default
key str

Secret key

required
Source code in src/harombe/security/vault.py
async def rotate_secret(self, key: str) -> None:
    """No-op for environment variables.

    Args:
        key: Secret key
    """
    pass

HashiCorpVault

Bases: VaultBackend

HashiCorp Vault integration.

Supports: - KV v2 secrets engine - Token authentication (default) - AppRole authentication - Token auto-renewal

Source code in src/harombe/security/vault.py
class HashiCorpVault(VaultBackend):
    """HashiCorp Vault integration.

    Supports:
    - KV v2 secrets engine
    - Token authentication (default)
    - AppRole authentication
    - Token auto-renewal
    """

    def __init__(
        self,
        vault_addr: str = "http://127.0.0.1:8200",
        vault_token: str | None = None,
        vault_namespace: str | None = None,
        mount_point: str = "secret",
        auto_renew: bool = True,
    ):
        """Initialize Vault client.

        Args:
            vault_addr: Vault server address
            vault_token: Vault token (or use VAULT_TOKEN env var)
            vault_namespace: Vault namespace (enterprise feature)
            mount_point: KV secrets engine mount point
            auto_renew: Automatically renew token
        """
        self.vault_addr = vault_addr
        self.vault_token = vault_token or os.getenv("VAULT_TOKEN")
        self.vault_namespace = vault_namespace
        self.mount_point = mount_point
        self.auto_renew = auto_renew

        if not self.vault_token:
            raise ValueError("Vault token required (set VAULT_TOKEN or pass vault_token)")

        self.client = httpx.AsyncClient(
            base_url=vault_addr,
            headers=self._get_headers(),
            timeout=30.0,
        )

        self._renewal_task: asyncio.Task | None = None
        self._token_ttl: int | None = None

    def _get_headers(self) -> dict[str, str]:
        """Get HTTP headers for Vault requests."""
        headers = {"X-Vault-Token": self.vault_token}
        if self.vault_namespace:
            headers["X-Vault-Namespace"] = self.vault_namespace
        return headers

    async def start(self) -> None:
        """Start token renewal background task."""
        if self.auto_renew:
            # Get current token TTL
            response = await self.client.get("/v1/auth/token/lookup-self")
            if response.status_code == 200:
                data = response.json()
                self._token_ttl = data["data"].get("ttl", 3600)

                # Start renewal task (renew at 50% of TTL)
                renewal_interval = self._token_ttl / 2
                self._renewal_task = asyncio.create_task(self._token_renewal_loop(renewal_interval))

    async def stop(self) -> None:
        """Stop token renewal and close client."""
        if self._renewal_task:
            self._renewal_task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await self._renewal_task

        await self.client.aclose()

    async def _token_renewal_loop(self, interval: float) -> None:
        """Background task to renew token periodically."""
        while True:
            try:
                await asyncio.sleep(interval)
                await self._renew_token()
            except asyncio.CancelledError:
                break
            except Exception as e:
                print(f"Token renewal failed: {e}")
                # Continue trying to renew

    async def _renew_token(self) -> None:
        """Renew the Vault token."""
        response = await self.client.post("/v1/auth/token/renew-self")
        if response.status_code == 200:
            data = response.json()
            self._token_ttl = data["auth"].get("lease_duration", 3600)

    async def get_secret(self, key: str) -> str | None:
        """Get secret from Vault KV v2.

        Args:
            key: Secret path (without mount point)

        Returns:
            Secret value or None
        """
        path = f"/v1/{self.mount_point}/data/{key}"

        try:
            response = await self.client.get(path)

            if response.status_code == 404:
                return None

            if response.status_code != 200:
                raise ValueError(f"Vault error: {response.status_code} - {response.text}")

            data = response.json()
            # KV v2 nests data under data.data
            secret_data = data.get("data", {}).get("data", {})

            # Return the "value" field if it exists, otherwise return first value
            if "value" in secret_data:
                return secret_data["value"]
            elif secret_data:
                return next(iter(secret_data.values()))

            return None

        except httpx.RequestError as e:
            raise ValueError(f"Failed to connect to Vault: {e}") from e

    async def set_secret(self, key: str, value: str, **metadata: Any) -> None:
        """Store secret in Vault KV v2.

        Args:
            key: Secret path
            value: Secret value
            metadata: Additional metadata
        """
        path = f"/v1/{self.mount_point}/data/{key}"

        # KV v2 requires data nested under "data"
        payload = {
            "data": {
                "value": value,
                **metadata,
            }
        }

        response = await self.client.post(path, json=payload)

        if response.status_code not in (200, 204):
            raise ValueError(f"Failed to store secret: {response.status_code} - {response.text}")

    async def delete_secret(self, key: str) -> None:
        """Delete secret from Vault.

        Args:
            key: Secret path
        """
        path = f"/v1/{self.mount_point}/data/{key}"
        response = await self.client.delete(path)

        if response.status_code not in (200, 204):
            raise ValueError(f"Failed to delete secret: {response.status_code}")

    async def list_secrets(self, prefix: str = "") -> list[str]:
        """List secret keys.

        Args:
            prefix: Key prefix filter

        Returns:
            List of secret keys
        """
        path = f"/v1/{self.mount_point}/metadata/{prefix}"

        response = await self.client.request("LIST", path)

        if response.status_code == 404:
            return []

        if response.status_code != 200:
            raise ValueError(f"Failed to list secrets: {response.status_code}")

        data = response.json()
        return data.get("data", {}).get("keys", [])

    async def rotate_secret(self, key: str) -> None:
        """Rotate a secret by creating a new version.

        Args:
            key: Secret path
        """
        # Get current secret
        current = await self.get_secret(key)
        if current is None:
            raise ValueError(f"Secret '{key}' not found")

        # For now, just update with same value to create new version
        # In production, you'd generate a new value here
        await self.set_secret(key, current)

__init__(vault_addr='http://127.0.0.1:8200', vault_token=None, vault_namespace=None, mount_point='secret', auto_renew=True)

Initialize Vault client.

Parameters:

Name Type Description Default
vault_addr str

Vault server address

'http://127.0.0.1:8200'
vault_token str | None

Vault token (or use VAULT_TOKEN env var)

None
vault_namespace str | None

Vault namespace (enterprise feature)

None
mount_point str

KV secrets engine mount point

'secret'
auto_renew bool

Automatically renew token

True
Source code in src/harombe/security/vault.py
def __init__(
    self,
    vault_addr: str = "http://127.0.0.1:8200",
    vault_token: str | None = None,
    vault_namespace: str | None = None,
    mount_point: str = "secret",
    auto_renew: bool = True,
):
    """Initialize Vault client.

    Args:
        vault_addr: Vault server address
        vault_token: Vault token (or use VAULT_TOKEN env var)
        vault_namespace: Vault namespace (enterprise feature)
        mount_point: KV secrets engine mount point
        auto_renew: Automatically renew token
    """
    self.vault_addr = vault_addr
    self.vault_token = vault_token or os.getenv("VAULT_TOKEN")
    self.vault_namespace = vault_namespace
    self.mount_point = mount_point
    self.auto_renew = auto_renew

    if not self.vault_token:
        raise ValueError("Vault token required (set VAULT_TOKEN or pass vault_token)")

    self.client = httpx.AsyncClient(
        base_url=vault_addr,
        headers=self._get_headers(),
        timeout=30.0,
    )

    self._renewal_task: asyncio.Task | None = None
    self._token_ttl: int | None = None

start() async

Start token renewal background task.

Source code in src/harombe/security/vault.py
async def start(self) -> None:
    """Start token renewal background task."""
    if self.auto_renew:
        # Get current token TTL
        response = await self.client.get("/v1/auth/token/lookup-self")
        if response.status_code == 200:
            data = response.json()
            self._token_ttl = data["data"].get("ttl", 3600)

            # Start renewal task (renew at 50% of TTL)
            renewal_interval = self._token_ttl / 2
            self._renewal_task = asyncio.create_task(self._token_renewal_loop(renewal_interval))

stop() async

Stop token renewal and close client.

Source code in src/harombe/security/vault.py
async def stop(self) -> None:
    """Stop token renewal and close client."""
    if self._renewal_task:
        self._renewal_task.cancel()
        with contextlib.suppress(asyncio.CancelledError):
            await self._renewal_task

    await self.client.aclose()

get_secret(key) async

Get secret from Vault KV v2.

Parameters:

Name Type Description Default
key str

Secret path (without mount point)

required

Returns:

Type Description
str | None

Secret value or None

Source code in src/harombe/security/vault.py
async def get_secret(self, key: str) -> str | None:
    """Get secret from Vault KV v2.

    Args:
        key: Secret path (without mount point)

    Returns:
        Secret value or None
    """
    path = f"/v1/{self.mount_point}/data/{key}"

    try:
        response = await self.client.get(path)

        if response.status_code == 404:
            return None

        if response.status_code != 200:
            raise ValueError(f"Vault error: {response.status_code} - {response.text}")

        data = response.json()
        # KV v2 nests data under data.data
        secret_data = data.get("data", {}).get("data", {})

        # Return the "value" field if it exists, otherwise return first value
        if "value" in secret_data:
            return secret_data["value"]
        elif secret_data:
            return next(iter(secret_data.values()))

        return None

    except httpx.RequestError as e:
        raise ValueError(f"Failed to connect to Vault: {e}") from e

set_secret(key, value, **metadata) async

Store secret in Vault KV v2.

Parameters:

Name Type Description Default
key str

Secret path

required
value str

Secret value

required
metadata Any

Additional metadata

{}
Source code in src/harombe/security/vault.py
async def set_secret(self, key: str, value: str, **metadata: Any) -> None:
    """Store secret in Vault KV v2.

    Args:
        key: Secret path
        value: Secret value
        metadata: Additional metadata
    """
    path = f"/v1/{self.mount_point}/data/{key}"

    # KV v2 requires data nested under "data"
    payload = {
        "data": {
            "value": value,
            **metadata,
        }
    }

    response = await self.client.post(path, json=payload)

    if response.status_code not in (200, 204):
        raise ValueError(f"Failed to store secret: {response.status_code} - {response.text}")

delete_secret(key) async

Delete secret from Vault.

Parameters:

Name Type Description Default
key str

Secret path

required
Source code in src/harombe/security/vault.py
async def delete_secret(self, key: str) -> None:
    """Delete secret from Vault.

    Args:
        key: Secret path
    """
    path = f"/v1/{self.mount_point}/data/{key}"
    response = await self.client.delete(path)

    if response.status_code not in (200, 204):
        raise ValueError(f"Failed to delete secret: {response.status_code}")

list_secrets(prefix='') async

List secret keys.

Parameters:

Name Type Description Default
prefix str

Key prefix filter

''

Returns:

Type Description
list[str]

List of secret keys

Source code in src/harombe/security/vault.py
async def list_secrets(self, prefix: str = "") -> list[str]:
    """List secret keys.

    Args:
        prefix: Key prefix filter

    Returns:
        List of secret keys
    """
    path = f"/v1/{self.mount_point}/metadata/{prefix}"

    response = await self.client.request("LIST", path)

    if response.status_code == 404:
        return []

    if response.status_code != 200:
        raise ValueError(f"Failed to list secrets: {response.status_code}")

    data = response.json()
    return data.get("data", {}).get("keys", [])

rotate_secret(key) async

Rotate a secret by creating a new version.

Parameters:

Name Type Description Default
key str

Secret path

required
Source code in src/harombe/security/vault.py
async def rotate_secret(self, key: str) -> None:
    """Rotate a secret by creating a new version.

    Args:
        key: Secret path
    """
    # Get current secret
    current = await self.get_secret(key)
    if current is None:
        raise ValueError(f"Secret '{key}' not found")

    # For now, just update with same value to create new version
    # In production, you'd generate a new value here
    await self.set_secret(key, current)

SOPSBackend

Bases: VaultBackend

SOPS (Secrets OPerationS) file encryption backend.

Simpler alternative to Vault for small deployments. Uses age or GPG for encryption.

Source code in src/harombe/security/vault.py
class SOPSBackend(VaultBackend):
    """SOPS (Secrets OPerationS) file encryption backend.

    Simpler alternative to Vault for small deployments.
    Uses age or GPG for encryption.
    """

    def __init__(
        self,
        secrets_file: str = "~/.harombe/secrets.enc.json",
        key_file: str | None = None,
    ):
        """Initialize SOPS backend.

        Args:
            secrets_file: Path to encrypted secrets file
            key_file: Path to age key file (default: ~/.config/sops/age/keys.txt)
        """
        self.secrets_file = Path(secrets_file).expanduser()
        self.key_file = key_file
        self._secrets_cache: dict[str, str] = {}
        self._cache_loaded = False

    async def _load_secrets(self) -> None:
        """Load and decrypt secrets file."""
        if not self.secrets_file.exists():
            self._secrets_cache = {}
            self._cache_loaded = True
            return

        try:
            # Use sops to decrypt
            env = os.environ.copy()
            if self.key_file:
                env["SOPS_AGE_KEY_FILE"] = self.key_file

            result = subprocess.run(
                ["sops", "--decrypt", str(self.secrets_file)],
                capture_output=True,
                text=True,
                check=True,
                env=env,
            )

            self._secrets_cache = json.loads(result.stdout)
            self._cache_loaded = True

        except subprocess.CalledProcessError as e:
            raise ValueError(f"Failed to decrypt secrets with SOPS: {e.stderr}") from e
        except FileNotFoundError:
            raise ValueError(
                "sops binary not found. Install sops: https://github.com/getsops/sops"
            ) from None

    async def _save_secrets(self) -> None:
        """Encrypt and save secrets file."""
        # Create directory if needed
        self.secrets_file.parent.mkdir(parents=True, exist_ok=True)

        # Write plaintext temporarily
        temp_file = self.secrets_file.with_suffix(".tmp.json")
        with open(temp_file, "w") as f:
            json.dump(self._secrets_cache, f, indent=2)

        try:
            # Encrypt with sops
            env = os.environ.copy()
            if self.key_file:
                env["SOPS_AGE_KEY_FILE"] = self.key_file

            subprocess.run(
                ["sops", "--encrypt", "--in-place", str(temp_file)],
                check=True,
                env=env,
                capture_output=True,
            )

            # Move to final location
            temp_file.replace(self.secrets_file)

        except subprocess.CalledProcessError as e:
            temp_file.unlink(missing_ok=True)
            raise ValueError(f"Failed to encrypt secrets with SOPS: {e.stderr}") from e

    async def get_secret(self, key: str) -> str | None:
        """Get secret from encrypted file.

        Args:
            key: Secret key

        Returns:
            Secret value or None
        """
        if not self._cache_loaded:
            await self._load_secrets()

        return self._secrets_cache.get(key)

    async def set_secret(self, key: str, value: str, **metadata: Any) -> None:
        """Store secret in encrypted file.

        Args:
            key: Secret key
            value: Secret value
            metadata: Ignored for SOPS
        """
        if not self._cache_loaded:
            await self._load_secrets()

        self._secrets_cache[key] = value
        await self._save_secrets()

    async def delete_secret(self, key: str) -> None:
        """Delete secret.

        Args:
            key: Secret key
        """
        if not self._cache_loaded:
            await self._load_secrets()

        if key in self._secrets_cache:
            del self._secrets_cache[key]
            await self._save_secrets()

    async def list_secrets(self, prefix: str = "") -> list[str]:
        """List secret keys.

        Args:
            prefix: Key prefix filter

        Returns:
            List of matching keys
        """
        if not self._cache_loaded:
            await self._load_secrets()

        if prefix:
            return [k for k in self._secrets_cache if k.startswith(prefix)]
        return list(self._secrets_cache.keys())

    async def rotate_secret(self, key: str) -> None:
        """Rotate secret (no-op for SOPS, just reload).

        Args:
            key: Secret key
        """
        # For SOPS, rotation means external process updates the file
        # We just reload
        self._cache_loaded = False
        await self._load_secrets()

__init__(secrets_file='~/.harombe/secrets.enc.json', key_file=None)

Initialize SOPS backend.

Parameters:

Name Type Description Default
secrets_file str

Path to encrypted secrets file

'~/.harombe/secrets.enc.json'
key_file str | None

Path to age key file (default: ~/.config/sops/age/keys.txt)

None
Source code in src/harombe/security/vault.py
def __init__(
    self,
    secrets_file: str = "~/.harombe/secrets.enc.json",
    key_file: str | None = None,
):
    """Initialize SOPS backend.

    Args:
        secrets_file: Path to encrypted secrets file
        key_file: Path to age key file (default: ~/.config/sops/age/keys.txt)
    """
    self.secrets_file = Path(secrets_file).expanduser()
    self.key_file = key_file
    self._secrets_cache: dict[str, str] = {}
    self._cache_loaded = False

get_secret(key) async

Get secret from encrypted file.

Parameters:

Name Type Description Default
key str

Secret key

required

Returns:

Type Description
str | None

Secret value or None

Source code in src/harombe/security/vault.py
async def get_secret(self, key: str) -> str | None:
    """Get secret from encrypted file.

    Args:
        key: Secret key

    Returns:
        Secret value or None
    """
    if not self._cache_loaded:
        await self._load_secrets()

    return self._secrets_cache.get(key)

set_secret(key, value, **metadata) async

Store secret in encrypted file.

Parameters:

Name Type Description Default
key str

Secret key

required
value str

Secret value

required
metadata Any

Ignored for SOPS

{}
Source code in src/harombe/security/vault.py
async def set_secret(self, key: str, value: str, **metadata: Any) -> None:
    """Store secret in encrypted file.

    Args:
        key: Secret key
        value: Secret value
        metadata: Ignored for SOPS
    """
    if not self._cache_loaded:
        await self._load_secrets()

    self._secrets_cache[key] = value
    await self._save_secrets()

delete_secret(key) async

Delete secret.

Parameters:

Name Type Description Default
key str

Secret key

required
Source code in src/harombe/security/vault.py
async def delete_secret(self, key: str) -> None:
    """Delete secret.

    Args:
        key: Secret key
    """
    if not self._cache_loaded:
        await self._load_secrets()

    if key in self._secrets_cache:
        del self._secrets_cache[key]
        await self._save_secrets()

list_secrets(prefix='') async

List secret keys.

Parameters:

Name Type Description Default
prefix str

Key prefix filter

''

Returns:

Type Description
list[str]

List of matching keys

Source code in src/harombe/security/vault.py
async def list_secrets(self, prefix: str = "") -> list[str]:
    """List secret keys.

    Args:
        prefix: Key prefix filter

    Returns:
        List of matching keys
    """
    if not self._cache_loaded:
        await self._load_secrets()

    if prefix:
        return [k for k in self._secrets_cache if k.startswith(prefix)]
    return list(self._secrets_cache.keys())

rotate_secret(key) async

Rotate secret (no-op for SOPS, just reload).

Parameters:

Name Type Description Default
key str

Secret key

required
Source code in src/harombe/security/vault.py
async def rotate_secret(self, key: str) -> None:
    """Rotate secret (no-op for SOPS, just reload).

    Args:
        key: Secret key
    """
    # For SOPS, rotation means external process updates the file
    # We just reload
    self._cache_loaded = False
    await self._load_secrets()

VaultBackend

Bases: ABC

Abstract base class for secret vault backends.

Source code in src/harombe/security/vault.py
class VaultBackend(ABC):
    """Abstract base class for secret vault backends."""

    @abstractmethod
    async def get_secret(self, key: str) -> str | None:
        """Retrieve a secret by key.

        Args:
            key: Secret key/path

        Returns:
            Secret value or None if not found
        """
        pass

    @abstractmethod
    async def set_secret(self, key: str, value: str, **metadata: Any) -> None:
        """Store a secret.

        Args:
            key: Secret key/path
            value: Secret value
            metadata: Additional metadata
        """
        pass

    @abstractmethod
    async def delete_secret(self, key: str) -> None:
        """Delete a secret.

        Args:
            key: Secret key/path
        """
        pass

    @abstractmethod
    async def list_secrets(self, prefix: str = "") -> list[str]:
        """List all secret keys with optional prefix.

        Args:
            prefix: Optional key prefix filter

        Returns:
            List of secret keys
        """
        pass

    @abstractmethod
    async def rotate_secret(self, key: str) -> None:
        """Rotate a secret (create new version).

        Args:
            key: Secret key/path
        """
        pass

get_secret(key) abstractmethod async

Retrieve a secret by key.

Parameters:

Name Type Description Default
key str

Secret key/path

required

Returns:

Type Description
str | None

Secret value or None if not found

Source code in src/harombe/security/vault.py
@abstractmethod
async def get_secret(self, key: str) -> str | None:
    """Retrieve a secret by key.

    Args:
        key: Secret key/path

    Returns:
        Secret value or None if not found
    """
    pass

set_secret(key, value, **metadata) abstractmethod async

Store a secret.

Parameters:

Name Type Description Default
key str

Secret key/path

required
value str

Secret value

required
metadata Any

Additional metadata

{}
Source code in src/harombe/security/vault.py
@abstractmethod
async def set_secret(self, key: str, value: str, **metadata: Any) -> None:
    """Store a secret.

    Args:
        key: Secret key/path
        value: Secret value
        metadata: Additional metadata
    """
    pass

delete_secret(key) abstractmethod async

Delete a secret.

Parameters:

Name Type Description Default
key str

Secret key/path

required
Source code in src/harombe/security/vault.py
@abstractmethod
async def delete_secret(self, key: str) -> None:
    """Delete a secret.

    Args:
        key: Secret key/path
    """
    pass

list_secrets(prefix='') abstractmethod async

List all secret keys with optional prefix.

Parameters:

Name Type Description Default
prefix str

Optional key prefix filter

''

Returns:

Type Description
list[str]

List of secret keys

Source code in src/harombe/security/vault.py
@abstractmethod
async def list_secrets(self, prefix: str = "") -> list[str]:
    """List all secret keys with optional prefix.

    Args:
        prefix: Optional key prefix filter

    Returns:
        List of secret keys
    """
    pass

rotate_secret(key) abstractmethod async

Rotate a secret (create new version).

Parameters:

Name Type Description Default
key str

Secret key/path

required
Source code in src/harombe/security/vault.py
@abstractmethod
async def rotate_secret(self, key: str) -> None:
    """Rotate a secret (create new version).

    Args:
        key: Secret key/path
    """
    pass

get_browser_hitl_rules()

Get HITL rules for browser automation tools.

Returns:

Type Description
list[HITLRule]

List of HITL rules for browser tools

Source code in src/harombe/security/browser_risk.py
def get_browser_hitl_rules() -> list[HITLRule]:
    """Get HITL rules for browser automation tools.

    Returns:
        List of HITL rules for browser tools
    """
    return [
        # Navigation rules
        HITLRule(
            tools=["browser_navigate"],
            risk=RiskLevel.CRITICAL,
            conditions=[
                {
                    "param": "url",
                    "matches": r"(?i)(bank|payment|paypal|stripe|checkout|purchase)",
                }
            ],
            timeout=30,
            description="Navigation to financial/payment sites",
        ),
        HITLRule(
            tools=["browser_navigate"],
            risk=RiskLevel.HIGH,
            conditions=[
                {
                    "param": "url",
                    "matches": r"(?i)(mail|email|admin|settings|account|profile)",
                }
            ],
            timeout=60,
            description="Navigation to sensitive domains (email, admin, settings)",
        ),
        HITLRule(
            tools=["browser_navigate"],
            risk=RiskLevel.MEDIUM,
            conditions=[
                # New domain (different from current page)
                # Note: This requires runtime check in gateway
            ],
            timeout=120,
            description="Navigation to new domain",
        ),
        HITLRule(
            tools=["browser_navigate"],
            risk=RiskLevel.LOW,
            require_approval=False,
            description="Navigation within same domain (safe)",
        ),
        # Click rules
        HITLRule(
            tools=["browser_click"],
            risk=RiskLevel.CRITICAL,
            conditions=[
                {
                    "param": "name",
                    "matches": r"(?i)(delete account|close account|terminate|deactivate account|remove account)",
                }
            ],
            timeout=30,
            description="Account deletion/termination buttons",
        ),
        HITLRule(
            tools=["browser_click"],
            risk=RiskLevel.HIGH,
            conditions=[
                {
                    "param": "name",
                    "matches": r"(?i)(delete|remove|revoke|cancel|unsubscribe|disconnect|sign out)",
                }
            ],
            timeout=60,
            description="Destructive actions (delete, remove, revoke)",
        ),
        HITLRule(
            tools=["browser_click"],
            risk=RiskLevel.HIGH,
            conditions=[
                {
                    "param": "name",
                    "matches": r"(?i)(send|submit|post|publish|share|transfer|pay|purchase|buy)",
                }
            ],
            timeout=60,
            description="Communication/transaction actions (send, submit, pay)",
        ),
        HITLRule(
            tools=["browser_click"],
            risk=RiskLevel.MEDIUM,
            conditions=[
                {
                    "param": "name",
                    "matches": r"(?i)(save|update|edit|modify|change|add|create)",
                }
            ],
            timeout=120,
            description="Modification actions (save, update, create)",
        ),
        HITLRule(
            tools=["browser_click"],
            risk=RiskLevel.LOW,
            require_approval=False,
            description="Navigation clicks (safe)",
        ),
        # Type rules
        HITLRule(
            tools=["browser_type"],
            risk=RiskLevel.CRITICAL,
            conditions=[
                {
                    "param": "name",
                    "matches": r"(?i)(password|secret|token|key|api.?key)",
                }
            ],
            require_approval=False,  # Auto-deny (handled in tool)
            description="Password field typing (auto-denied)",
        ),
        HITLRule(
            tools=["browser_type"],
            risk=RiskLevel.HIGH,
            conditions=[
                {
                    "param": "name",
                    "matches": r"(?i)(credit.?card|card.?number|cvv|ssn|social.?security)",
                }
            ],
            timeout=60,
            description="Sensitive data fields (credit card, SSN)",
        ),
        HITLRule(
            tools=["browser_type"],
            risk=RiskLevel.MEDIUM,
            conditions=[
                {
                    "param": "name",
                    "matches": r"(?i)(email|address|phone|name|message|comment|note)",
                }
            ],
            timeout=120,
            description="Personal information fields",
        ),
        HITLRule(
            tools=["browser_type"],
            risk=RiskLevel.LOW,
            require_approval=False,
            description="Search and filter inputs (safe)",
        ),
        # Read rules (always low risk)
        HITLRule(
            tools=["browser_read", "browser_screenshot"],
            risk=RiskLevel.LOW,
            require_approval=False,
            description="Read-only operations",
        ),
        # Session management
        HITLRule(
            tools=["browser_close_session"],
            risk=RiskLevel.LOW,
            require_approval=False,
            description="Session cleanup",
        ),
    ]

get_sensitive_domains()

Get list of sensitive domains that always require approval.

Returns:

Type Description
list[str]

List of sensitive domains

Source code in src/harombe/security/browser_risk.py
def get_sensitive_domains() -> list[str]:
    """Get list of sensitive domains that always require approval.

    Returns:
        List of sensitive domains
    """
    return [
        # Email
        "mail.google.com",
        "gmail.com",
        "outlook.com",
        "outlook.office.com",
        # Financial
        "paypal.com",
        "stripe.com",
        "square.com",
        # Banking (wildcards)
        "*.bank",
        "*.banking",
        # Admin/settings
        "admin.*",
        "*/admin",
        "*/settings",
        "*/account",
    ]

get_trusted_domains()

Get list of trusted domains that don't require approval for navigation.

Users can configure this list in their harombe.yaml:

security:
  browser:
    trusted_domains:
      - github.com
      - stackoverflow.com
      - docs.python.org

Returns:

Type Description
list[str]

List of trusted domains

Source code in src/harombe/security/browser_risk.py
def get_trusted_domains() -> list[str]:
    """Get list of trusted domains that don't require approval for navigation.

    Users can configure this list in their harombe.yaml:
    ```yaml
    security:
      browser:
        trusted_domains:
          - github.com
          - stackoverflow.com
          - docs.python.org
    ```

    Returns:
        List of trusted domains
    """
    # Default trusted domains (can be overridden in config)
    return [
        # Development resources
        "github.com",
        "gitlab.com",
        "stackoverflow.com",
        "stackexchange.com",
        # Documentation
        "docs.python.org",
        "developer.mozilla.org",
        "w3.org",
        # Search engines (read-only)
        "google.com",
        "duckduckgo.com",
        "bing.com",
    ]

create_prompt(mode='cli', console=None)

Create approval prompt for specified mode.

Parameters:

Name Type Description Default
mode str

"cli" or "api"

'cli'
console Console | None

Optional console for CLI mode

None

Returns:

Type Description
CLIApprovalPrompt | APIApprovalPrompt

Approval prompt instance

Source code in src/harombe/security/hitl_prompt.py
def create_prompt(
    mode: str = "cli", console: Console | None = None
) -> CLIApprovalPrompt | APIApprovalPrompt:
    """
    Create approval prompt for specified mode.

    Args:
        mode: "cli" or "api"
        console: Optional console for CLI mode

    Returns:
        Approval prompt instance
    """
    if mode == "cli":
        return CLIApprovalPrompt(console=console)
    elif mode == "api":
        return APIApprovalPrompt()
    else:
        raise ValueError(f"Unknown prompt mode: {mode}")

create_injector(provider='env', **vault_kwargs)

Create a secret injector with vault backend.

Parameters:

Name Type Description Default
provider str

Vault provider (vault, sops, env)

'env'
vault_kwargs Any

Vault backend configuration

{}

Returns:

Type Description
SecretInjector

SecretInjector instance

Source code in src/harombe/security/injection.py
def create_injector(
    provider: str = "env",
    **vault_kwargs: Any,
) -> SecretInjector:
    """Create a secret injector with vault backend.

    Args:
        provider: Vault provider (vault, sops, env)
        vault_kwargs: Vault backend configuration

    Returns:
        SecretInjector instance
    """
    vault = create_vault_backend(provider, **vault_kwargs)
    return SecretInjector(vault_backend=vault)

get_allowed_registries()

Get allowed package registries by language.

Returns:

Type Description
dict[str, list[str]]

Dictionary mapping language to allowed registries

Source code in src/harombe/security/sandbox_risk.py
def get_allowed_registries() -> dict[str, list[str]]:
    """Get allowed package registries by language.

    Returns:
        Dictionary mapping language to allowed registries
    """
    return {
        "python": ["pypi"],
        "javascript": ["npm"],
        "shell": [],  # No package installation for shell
    }

get_sandbox_hitl_rules()

Get HITL rules for code execution sandbox tools.

Returns:

Type Description
list[HITLRule]

List of HITL rules for sandbox tools

Source code in src/harombe/security/sandbox_risk.py
def get_sandbox_hitl_rules() -> list[HITLRule]:
    """Get HITL rules for code execution sandbox tools.

    Returns:
        List of HITL rules for sandbox tools
    """
    return [
        # Code execution with network - CRITICAL
        HITLRule(
            tools=["code_execute"],
            risk=RiskLevel.CRITICAL,
            conditions=[{"param": "network_enabled", "equals": True}],
            timeout=30,
            description="Code execution with network access",
        ),
        # Dangerous code patterns - CRITICAL
        HITLRule(
            tools=["code_execute"],
            risk=RiskLevel.CRITICAL,
            conditions=[
                {
                    "param": "code",
                    "matches": r"(?i)(rm\s+-rf|curl.*\|\s*sh|wget.*\|\s*sh|eval\(|exec\(|__import__|subprocess|os\.system)",
                }
            ],
            timeout=30,
            description="Dangerous code patterns detected (rm -rf, eval, exec, subprocess)",
        ),
        # Any code execution - HIGH
        HITLRule(
            tools=["code_execute"],
            risk=RiskLevel.HIGH,
            require_approval=True,
            timeout=60,
            description="Code execution in sandbox",
        ),
        # Package installation from standard registries - HIGH
        HITLRule(
            tools=["code_install_package"],
            risk=RiskLevel.HIGH,
            conditions=[
                {"param": "registry", "matches": r"^(pypi|npm)$"},
            ],
            timeout=60,
            description="Package installation from standard registry",
        ),
        # Package installation from non-standard registry - CRITICAL
        HITLRule(
            tools=["code_install_package"],
            risk=RiskLevel.CRITICAL,
            conditions=[
                {"param": "registry", "matches": r"^(?!pypi$|npm$)"},
            ],
            timeout=30,
            description="Package installation from non-standard registry",
        ),
        # Writing executable files - HIGH
        HITLRule(
            tools=["code_write_file"],
            risk=RiskLevel.HIGH,
            conditions=[
                {"param": "file_path", "matches": r"\.(sh|py|js|exe|bin)$"},
            ],
            timeout=60,
            description="Writing executable file",
        ),
        # Writing files - MEDIUM
        HITLRule(
            tools=["code_write_file"],
            risk=RiskLevel.MEDIUM,
            timeout=120,
            description="Writing file to sandbox workspace",
        ),
        # Reading files - MEDIUM
        HITLRule(
            tools=["code_read_file"],
            risk=RiskLevel.MEDIUM,
            timeout=120,
            description="Reading file from sandbox workspace",
        ),
        # Listing files - MEDIUM
        HITLRule(
            tools=["code_list_files"],
            risk=RiskLevel.MEDIUM,
            timeout=120,
            description="Listing files in sandbox workspace",
        ),
        # Destroying sandbox - LOW (cleanup)
        HITLRule(
            tools=["code_destroy_sandbox"],
            risk=RiskLevel.LOW,
            require_approval=False,
            description="Sandbox cleanup",
        ),
    ]

create_vault_backend(provider='env', **kwargs)

Create a vault backend instance.

Parameters:

Name Type Description Default
provider str

Backend type (vault, sops, env)

'env'
kwargs Any

Backend-specific configuration

{}

Returns:

Type Description
VaultBackend

VaultBackend instance

Raises:

Type Description
ValueError

If provider is unknown

Source code in src/harombe/security/vault.py
def create_vault_backend(
    provider: str = "env",
    **kwargs: Any,
) -> VaultBackend:
    """Create a vault backend instance.

    Args:
        provider: Backend type (vault, sops, env)
        kwargs: Backend-specific configuration

    Returns:
        VaultBackend instance

    Raises:
        ValueError: If provider is unknown
    """
    if provider == "vault":
        return HashiCorpVault(**kwargs)
    elif provider == "sops":
        return SOPSBackend(**kwargs)
    elif provider == "env":
        return EnvVarBackend(**kwargs)
    else:
        raise ValueError(f"Unknown vault provider: {provider}. Use 'vault', 'sops', or 'env'")

options: show_root_heading: true show_if_no_docstring: false members_order: source

Privacy

Privacy-preserving routing for hybrid local/cloud AI.

harombe.privacy

Privacy-preserving routing for hybrid local/cloud AI.

The Privacy Router classifies query sensitivity, detects and redacts PII, sanitizes context, and routes to either a local or cloud LLM backend. From the Agent's perspective, it is just another LLM client.

Three routing modes are supported:

  • local-only - All queries stay on local hardware (maximum privacy)
  • hybrid (default) - Sensitive queries stay local, others may use cloud
  • cloud-assisted - Cloud used freely, PII still redacted

Components:

  • :class:PrivacyRouter - Main router implementing the LLM client interface
  • :class:SensitivityClassifier - Classifies query sensitivity level
  • :class:ContextSanitizer - Detects and redacts PII before cloud calls

SensitivityClassifier

Classifies text sensitivity for privacy-aware routing.

Source code in src/harombe/privacy/classifier.py
class SensitivityClassifier:
    """Classifies text sensitivity for privacy-aware routing."""

    PII_PATTERNS: ClassVar[dict[str, re.Pattern]] = {
        "ssn": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
        "phone": re.compile(r"\b(?:\+1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b"),
        "email": re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"),
        "ip_address": re.compile(
            r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b"
        ),
        "credit_card": re.compile(r"\b(?:\d{4}[-\s]?){3}\d{4}\b"),
        "date_of_birth": re.compile(
            r"\b(?:DOB|date of birth|born on|birthday)[:\s]+\d{1,2}[/\-]\d{1,2}[/\-]\d{2,4}\b",
            re.IGNORECASE,
        ),
        "address": re.compile(
            r"\b\d{1,5}\s+(?:[A-Z][a-z]+\s?){1,4}(?:St|Street|Ave|Avenue|Blvd|Boulevard|Dr|Drive|Rd|Road|Ln|Lane|Ct|Court|Way|Pl|Place)\b",
        ),
    }

    RESTRICTED_KEYWORDS: ClassVar[list[str]] = [
        "confidential",
        "restricted",
        "hipaa",
        "internal only",
        "do not share",
        "top secret",
        "classified",
        "proprietary",
        "attorney-client",
        "trade secret",
        "nda",
        "under embargo",
    ]

    def __init__(
        self,
        custom_patterns: dict[str, str] | None = None,
        custom_restricted_keywords: list[str] | None = None,
        secret_scanner: SecretScanner | None = None,
    ):
        """Initialize the sensitivity classifier.

        Args:
            custom_patterns: Additional regex patterns {name: pattern_str}
            custom_restricted_keywords: Additional restricted keywords
            secret_scanner: SecretScanner instance (creates default if None)
        """
        self.scanner = secret_scanner or SecretScanner(min_confidence=0.7)

        self._pii_patterns = dict(self.PII_PATTERNS)
        if custom_patterns:
            for name, pattern_str in custom_patterns.items():
                self._pii_patterns[name] = re.compile(pattern_str)

        self._restricted_keywords = list(self.RESTRICTED_KEYWORDS)
        if custom_restricted_keywords:
            self._restricted_keywords.extend(kw.lower() for kw in custom_restricted_keywords)

    def classify(
        self,
        query: str,
        messages: list[Message] | None = None,
    ) -> SensitivityResult:
        """Classify the sensitivity of a query and conversation context.

        Args:
            query: The latest user query
            messages: Full conversation history (optional)

        Returns:
            SensitivityResult with level, reasons, and detected entities
        """
        reasons: list[str] = []
        entities: list[PIIEntity] = []
        pii_locations: list[tuple[int, int]] = []

        # Combine query + recent messages for scanning
        text_to_scan = query
        if messages:
            recent_content = [m.content for m in messages[-5:] if m.role == "user" and m.content]
            text_to_scan = "\n".join([*recent_content, query])

        # 1. Check restricted keywords (highest priority)
        keyword_match = self._check_keywords(text_to_scan)
        if keyword_match:
            reasons.append(f"Restricted keyword detected: '{keyword_match}'")
            return SensitivityResult(
                level=SensitivityLevel.RESTRICTED,
                reasons=reasons,
                detected_entities=entities,
                confidence=0.95,
                pii_locations=pii_locations,
            )

        # 2. Scan for credentials/secrets
        secret_matches = self.scanner.scan(text_to_scan)
        for match in secret_matches:
            entities.append(
                PIIEntity(
                    type=f"credential:{match.type.value}",
                    value=match.value,
                    start=match.start,
                    end=match.end,
                    confidence=match.confidence,
                )
            )
            pii_locations.append((match.start, match.end))
            reasons.append(f"Credential detected: {match.type.value}")

        # 3. Scan for PII patterns
        for pii_type, pattern in self._pii_patterns.items():
            for match in pattern.finditer(text_to_scan):
                entities.append(
                    PIIEntity(
                        type=pii_type,
                        value=match.group(0),
                        start=match.start(),
                        end=match.end(),
                        confidence=0.9,
                    )
                )
                pii_locations.append((match.start(), match.end()))
                reasons.append(f"PII detected: {pii_type}")

        # Determine level based on findings
        if not entities:
            return SensitivityResult(
                level=SensitivityLevel.PUBLIC,
                reasons=["No sensitive data detected"],
                detected_entities=[],
                confidence=0.85,
                pii_locations=[],
            )

        has_credentials = any(e.type.startswith("credential:") for e in entities)
        has_pii = any(not e.type.startswith("credential:") for e in entities)

        if has_credentials:
            level = SensitivityLevel.CONFIDENTIAL
            confidence = max(e.confidence for e in entities)
        elif has_pii:
            level = SensitivityLevel.INTERNAL
            confidence = max(e.confidence for e in entities)
        else:
            level = SensitivityLevel.PUBLIC
            confidence = 0.85

        return SensitivityResult(
            level=level,
            reasons=reasons,
            detected_entities=entities,
            confidence=confidence,
            pii_locations=pii_locations,
        )

    def _check_keywords(self, text: str) -> str | None:
        """Check for restricted keywords.

        Args:
            text: Text to check

        Returns:
            Matched keyword or None
        """
        text_lower = text.lower()
        for keyword in self._restricted_keywords:
            if keyword in text_lower:
                return keyword
        return None

__init__(custom_patterns=None, custom_restricted_keywords=None, secret_scanner=None)

Initialize the sensitivity classifier.

Parameters:

Name Type Description Default
custom_patterns dict[str, str] | None

Additional regex patterns {name: pattern_str}

None
custom_restricted_keywords list[str] | None

Additional restricted keywords

None
secret_scanner SecretScanner | None

SecretScanner instance (creates default if None)

None
Source code in src/harombe/privacy/classifier.py
def __init__(
    self,
    custom_patterns: dict[str, str] | None = None,
    custom_restricted_keywords: list[str] | None = None,
    secret_scanner: SecretScanner | None = None,
):
    """Initialize the sensitivity classifier.

    Args:
        custom_patterns: Additional regex patterns {name: pattern_str}
        custom_restricted_keywords: Additional restricted keywords
        secret_scanner: SecretScanner instance (creates default if None)
    """
    self.scanner = secret_scanner or SecretScanner(min_confidence=0.7)

    self._pii_patterns = dict(self.PII_PATTERNS)
    if custom_patterns:
        for name, pattern_str in custom_patterns.items():
            self._pii_patterns[name] = re.compile(pattern_str)

    self._restricted_keywords = list(self.RESTRICTED_KEYWORDS)
    if custom_restricted_keywords:
        self._restricted_keywords.extend(kw.lower() for kw in custom_restricted_keywords)

classify(query, messages=None)

Classify the sensitivity of a query and conversation context.

Parameters:

Name Type Description Default
query str

The latest user query

required
messages list[Message] | None

Full conversation history (optional)

None

Returns:

Type Description
SensitivityResult

SensitivityResult with level, reasons, and detected entities

Source code in src/harombe/privacy/classifier.py
def classify(
    self,
    query: str,
    messages: list[Message] | None = None,
) -> SensitivityResult:
    """Classify the sensitivity of a query and conversation context.

    Args:
        query: The latest user query
        messages: Full conversation history (optional)

    Returns:
        SensitivityResult with level, reasons, and detected entities
    """
    reasons: list[str] = []
    entities: list[PIIEntity] = []
    pii_locations: list[tuple[int, int]] = []

    # Combine query + recent messages for scanning
    text_to_scan = query
    if messages:
        recent_content = [m.content for m in messages[-5:] if m.role == "user" and m.content]
        text_to_scan = "\n".join([*recent_content, query])

    # 1. Check restricted keywords (highest priority)
    keyword_match = self._check_keywords(text_to_scan)
    if keyword_match:
        reasons.append(f"Restricted keyword detected: '{keyword_match}'")
        return SensitivityResult(
            level=SensitivityLevel.RESTRICTED,
            reasons=reasons,
            detected_entities=entities,
            confidence=0.95,
            pii_locations=pii_locations,
        )

    # 2. Scan for credentials/secrets
    secret_matches = self.scanner.scan(text_to_scan)
    for match in secret_matches:
        entities.append(
            PIIEntity(
                type=f"credential:{match.type.value}",
                value=match.value,
                start=match.start,
                end=match.end,
                confidence=match.confidence,
            )
        )
        pii_locations.append((match.start, match.end))
        reasons.append(f"Credential detected: {match.type.value}")

    # 3. Scan for PII patterns
    for pii_type, pattern in self._pii_patterns.items():
        for match in pattern.finditer(text_to_scan):
            entities.append(
                PIIEntity(
                    type=pii_type,
                    value=match.group(0),
                    start=match.start(),
                    end=match.end(),
                    confidence=0.9,
                )
            )
            pii_locations.append((match.start(), match.end()))
            reasons.append(f"PII detected: {pii_type}")

    # Determine level based on findings
    if not entities:
        return SensitivityResult(
            level=SensitivityLevel.PUBLIC,
            reasons=["No sensitive data detected"],
            detected_entities=[],
            confidence=0.85,
            pii_locations=[],
        )

    has_credentials = any(e.type.startswith("credential:") for e in entities)
    has_pii = any(not e.type.startswith("credential:") for e in entities)

    if has_credentials:
        level = SensitivityLevel.CONFIDENTIAL
        confidence = max(e.confidence for e in entities)
    elif has_pii:
        level = SensitivityLevel.INTERNAL
        confidence = max(e.confidence for e in entities)
    else:
        level = SensitivityLevel.PUBLIC
        confidence = 0.85

    return SensitivityResult(
        level=level,
        reasons=reasons,
        detected_entities=entities,
        confidence=confidence,
        pii_locations=pii_locations,
    )

PIIEntity dataclass

A detected PII entity with location information.

Source code in src/harombe/privacy/models.py
@dataclass
class PIIEntity:
    """A detected PII entity with location information."""

    type: str  # e.g. "email", "ssn", "phone", "credit_card", "credential"
    value: str
    start: int
    end: int
    confidence: float

PrivacyRoutingDecision dataclass

Record of a routing decision for audit purposes.

Source code in src/harombe/privacy/models.py
@dataclass
class PrivacyRoutingDecision:
    """Record of a routing decision for audit purposes."""

    query_hash: str
    sensitivity: SensitivityResult
    target: RoutingTarget
    mode: RoutingMode
    was_sanitized: bool
    sanitized_entity_count: int
    reasoning: str
    timestamp: float = field(default_factory=time.time)

    @staticmethod
    def hash_query(query: str) -> str:
        return hashlib.sha256(query.encode()).hexdigest()[:16]

RoutingMode

Bases: StrEnum

How the privacy router should handle queries.

Source code in src/harombe/privacy/models.py
class RoutingMode(StrEnum):
    """How the privacy router should handle queries."""

    LOCAL_ONLY = "local-only"
    HYBRID = "hybrid"
    CLOUD_ASSISTED = "cloud-assisted"

RoutingTarget

Bases: StrEnum

Where a query is actually sent.

Source code in src/harombe/privacy/models.py
class RoutingTarget(StrEnum):
    """Where a query is actually sent."""

    LOCAL = "local"
    CLOUD = "cloud"
    CLOUD_SANITIZED = "cloud_sanitized"

SanitizationMap dataclass

Mapping of placeholders to original values for response reconstruction.

Source code in src/harombe/privacy/models.py
@dataclass
class SanitizationMap:
    """Mapping of placeholders to original values for response reconstruction."""

    replacements: dict[str, str] = field(default_factory=dict)  # "[EMAIL_1]" -> "user@example.com"

    def add(self, placeholder: str, original: str) -> None:
        self.replacements[placeholder] = original

    def get_original(self, placeholder: str) -> str | None:
        return self.replacements.get(placeholder)

SensitivityLevel

Bases: Enum

Classification of query sensitivity.

Source code in src/harombe/privacy/models.py
class SensitivityLevel(Enum):
    """Classification of query sensitivity."""

    PUBLIC = 0  # Safe for cloud
    INTERNAL = 1  # Cloud OK with basic sanitization
    CONFIDENTIAL = 2  # PII/credentials detected; local-only or heavy sanitization
    RESTRICTED = 3  # User-defined restricted; always local-only

SensitivityResult dataclass

Result of sensitivity classification.

Source code in src/harombe/privacy/models.py
@dataclass
class SensitivityResult:
    """Result of sensitivity classification."""

    level: SensitivityLevel
    reasons: list[str]
    detected_entities: list[PIIEntity]
    confidence: float
    pii_locations: list[tuple[int, int]] = field(default_factory=list)

PrivacyRouter

LLM client that routes queries based on privacy classification.

Implements the LLMClient protocol so it can be used as a drop-in replacement for OllamaClient or any other LLM client.

Source code in src/harombe/privacy/router.py
class PrivacyRouter:
    """LLM client that routes queries based on privacy classification.

    Implements the LLMClient protocol so it can be used as a drop-in
    replacement for OllamaClient or any other LLM client.
    """

    def __init__(
        self,
        local_client: OllamaClient,
        cloud_client: AnthropicClient,
        mode: RoutingMode = RoutingMode.HYBRID,
        classifier: SensitivityClassifier | None = None,
        sanitizer: ContextSanitizer | None = None,
        audit_logger: PrivacyAuditLogger | None = None,
        reconstruct_responses: bool = True,
    ):
        """Initialize privacy router.

        Args:
            local_client: Local LLM client (Ollama)
            cloud_client: Cloud LLM client (Anthropic)
            mode: Routing mode
            classifier: Sensitivity classifier (creates default if None)
            sanitizer: Context sanitizer (creates default if None)
            audit_logger: Privacy audit logger (creates default if None)
            reconstruct_responses: Whether to restore original values in cloud responses
        """
        self.local_client = local_client
        self.cloud_client = cloud_client
        self.mode = mode
        self.classifier = classifier or SensitivityClassifier()
        self.sanitizer = sanitizer or ContextSanitizer()
        self.audit_logger = audit_logger or PrivacyAuditLogger()
        self.reconstruct_responses = reconstruct_responses

        # Stats
        self.routing_stats = {
            "local": 0,
            "cloud": 0,
            "cloud_sanitized": 0,
        }

    def _get_routing_target(self, sensitivity_level: SensitivityLevel) -> RoutingTarget:
        """Look up routing target from rules table.

        Args:
            sensitivity_level: Classified sensitivity level

        Returns:
            Routing target
        """
        return ROUTING_RULES[self.mode][sensitivity_level]

    def _extract_latest_query(self, messages: list[Message]) -> str:
        """Extract the latest user query from messages.

        Args:
            messages: Conversation messages

        Returns:
            Latest user message content
        """
        for msg in reversed(messages):
            if msg.role == "user" and msg.content:
                return msg.content
        return ""

    async def complete(
        self,
        messages: list[Message],
        tools: list[dict[str, Any]] | None = None,
        temperature: float | None = None,
        max_tokens: int | None = None,
    ) -> CompletionResponse:
        """Route and complete a request based on privacy classification.

        Args:
            messages: Conversation history
            tools: Available tools in OpenAI function format
            temperature: Sampling temperature override
            max_tokens: Maximum tokens to generate

        Returns:
            CompletionResponse from the appropriate backend
        """
        # 1. Extract latest user query
        query = self._extract_latest_query(messages)

        # 2. Classify sensitivity
        sensitivity = self.classifier.classify(query, messages)

        # 3. Determine routing target
        target = self._get_routing_target(sensitivity.level)

        # 4. Route to appropriate backend
        was_sanitized = False
        sanitized_entity_count = 0

        if target == RoutingTarget.LOCAL:
            response = await self.local_client.complete(messages, tools, temperature, max_tokens)

        elif target == RoutingTarget.CLOUD:
            response = await self.cloud_client.complete(messages, tools, temperature, max_tokens)

        elif target == RoutingTarget.CLOUD_SANITIZED:
            # Sanitize messages before sending to cloud
            sanitized_messages, san_map = self.sanitizer.sanitize_messages(
                messages, sensitivity.detected_entities
            )
            was_sanitized = True
            sanitized_entity_count = len(san_map.replacements)

            response = await self.cloud_client.complete(
                sanitized_messages, tools, temperature, max_tokens
            )

            # Reconstruct original values in response
            if self.reconstruct_responses and san_map.replacements:
                response = self.sanitizer.reconstruct_response(response, san_map)

        else:
            # Fallback to local
            response = await self.local_client.complete(messages, tools, temperature, max_tokens)

        # Update stats
        self.routing_stats[target.value] = self.routing_stats.get(target.value, 0) + 1

        # 5. Audit the decision
        reasoning = (
            f"Mode={self.mode.value}, "
            f"Sensitivity={sensitivity.level.name}, "
            f"Target={target.value}"
        )
        if sensitivity.reasons:
            reasoning += f", Reasons: {'; '.join(sensitivity.reasons[:3])}"

        decision = PrivacyRoutingDecision(
            query_hash=PrivacyRoutingDecision.hash_query(query),
            sensitivity=sensitivity,
            target=target,
            mode=self.mode,
            was_sanitized=was_sanitized,
            sanitized_entity_count=sanitized_entity_count,
            reasoning=reasoning,
        )
        self.audit_logger.log_routing_decision(decision)

        return response

    async def stream_complete(
        self,
        messages: list[Message],
        tools: list[dict[str, Any]] | None = None,
        temperature: float | None = None,
    ) -> AsyncIterator[str]:
        """Stream a completion with privacy routing.

        For stream mode, sanitization reconstruction is not supported
        (placeholders would need to be detected mid-stream). Falls back
        to local if sanitization would be needed.

        Args:
            messages: Conversation history
            tools: Available tools in OpenAI function format
            temperature: Sampling temperature override

        Yields:
            Content chunks as strings
        """
        query = self._extract_latest_query(messages)
        sensitivity = self.classifier.classify(query, messages)
        target = self._get_routing_target(sensitivity.level)

        # For streaming, sanitized cloud is downgraded to local
        # (can't reconstruct placeholders mid-stream)
        if target == RoutingTarget.CLOUD_SANITIZED:
            target = RoutingTarget.LOCAL

        if target == RoutingTarget.CLOUD:
            async for chunk in self.cloud_client.stream_complete(messages, tools, temperature):
                yield chunk
        else:
            async for chunk in self.local_client.stream_complete(messages, tools, temperature):
                yield chunk

    def get_stats(self) -> dict[str, Any]:
        """Get routing statistics.

        Returns:
            Dict with routing counts and current mode
        """
        total = sum(self.routing_stats.values())
        return {
            "mode": self.mode.value,
            "total_requests": total,
            "local_count": self.routing_stats.get("local", 0),
            "cloud_count": self.routing_stats.get("cloud", 0),
            "cloud_sanitized_count": self.routing_stats.get("cloud_sanitized", 0),
        }

__init__(local_client, cloud_client, mode=RoutingMode.HYBRID, classifier=None, sanitizer=None, audit_logger=None, reconstruct_responses=True)

Initialize privacy router.

Parameters:

Name Type Description Default
local_client OllamaClient

Local LLM client (Ollama)

required
cloud_client AnthropicClient

Cloud LLM client (Anthropic)

required
mode RoutingMode

Routing mode

HYBRID
classifier SensitivityClassifier | None

Sensitivity classifier (creates default if None)

None
sanitizer ContextSanitizer | None

Context sanitizer (creates default if None)

None
audit_logger PrivacyAuditLogger | None

Privacy audit logger (creates default if None)

None
reconstruct_responses bool

Whether to restore original values in cloud responses

True
Source code in src/harombe/privacy/router.py
def __init__(
    self,
    local_client: OllamaClient,
    cloud_client: AnthropicClient,
    mode: RoutingMode = RoutingMode.HYBRID,
    classifier: SensitivityClassifier | None = None,
    sanitizer: ContextSanitizer | None = None,
    audit_logger: PrivacyAuditLogger | None = None,
    reconstruct_responses: bool = True,
):
    """Initialize privacy router.

    Args:
        local_client: Local LLM client (Ollama)
        cloud_client: Cloud LLM client (Anthropic)
        mode: Routing mode
        classifier: Sensitivity classifier (creates default if None)
        sanitizer: Context sanitizer (creates default if None)
        audit_logger: Privacy audit logger (creates default if None)
        reconstruct_responses: Whether to restore original values in cloud responses
    """
    self.local_client = local_client
    self.cloud_client = cloud_client
    self.mode = mode
    self.classifier = classifier or SensitivityClassifier()
    self.sanitizer = sanitizer or ContextSanitizer()
    self.audit_logger = audit_logger or PrivacyAuditLogger()
    self.reconstruct_responses = reconstruct_responses

    # Stats
    self.routing_stats = {
        "local": 0,
        "cloud": 0,
        "cloud_sanitized": 0,
    }

complete(messages, tools=None, temperature=None, max_tokens=None) async

Route and complete a request based on privacy classification.

Parameters:

Name Type Description Default
messages list[Message]

Conversation history

required
tools list[dict[str, Any]] | None

Available tools in OpenAI function format

None
temperature float | None

Sampling temperature override

None
max_tokens int | None

Maximum tokens to generate

None

Returns:

Type Description
CompletionResponse

CompletionResponse from the appropriate backend

Source code in src/harombe/privacy/router.py
async def complete(
    self,
    messages: list[Message],
    tools: list[dict[str, Any]] | None = None,
    temperature: float | None = None,
    max_tokens: int | None = None,
) -> CompletionResponse:
    """Route and complete a request based on privacy classification.

    Args:
        messages: Conversation history
        tools: Available tools in OpenAI function format
        temperature: Sampling temperature override
        max_tokens: Maximum tokens to generate

    Returns:
        CompletionResponse from the appropriate backend
    """
    # 1. Extract latest user query
    query = self._extract_latest_query(messages)

    # 2. Classify sensitivity
    sensitivity = self.classifier.classify(query, messages)

    # 3. Determine routing target
    target = self._get_routing_target(sensitivity.level)

    # 4. Route to appropriate backend
    was_sanitized = False
    sanitized_entity_count = 0

    if target == RoutingTarget.LOCAL:
        response = await self.local_client.complete(messages, tools, temperature, max_tokens)

    elif target == RoutingTarget.CLOUD:
        response = await self.cloud_client.complete(messages, tools, temperature, max_tokens)

    elif target == RoutingTarget.CLOUD_SANITIZED:
        # Sanitize messages before sending to cloud
        sanitized_messages, san_map = self.sanitizer.sanitize_messages(
            messages, sensitivity.detected_entities
        )
        was_sanitized = True
        sanitized_entity_count = len(san_map.replacements)

        response = await self.cloud_client.complete(
            sanitized_messages, tools, temperature, max_tokens
        )

        # Reconstruct original values in response
        if self.reconstruct_responses and san_map.replacements:
            response = self.sanitizer.reconstruct_response(response, san_map)

    else:
        # Fallback to local
        response = await self.local_client.complete(messages, tools, temperature, max_tokens)

    # Update stats
    self.routing_stats[target.value] = self.routing_stats.get(target.value, 0) + 1

    # 5. Audit the decision
    reasoning = (
        f"Mode={self.mode.value}, "
        f"Sensitivity={sensitivity.level.name}, "
        f"Target={target.value}"
    )
    if sensitivity.reasons:
        reasoning += f", Reasons: {'; '.join(sensitivity.reasons[:3])}"

    decision = PrivacyRoutingDecision(
        query_hash=PrivacyRoutingDecision.hash_query(query),
        sensitivity=sensitivity,
        target=target,
        mode=self.mode,
        was_sanitized=was_sanitized,
        sanitized_entity_count=sanitized_entity_count,
        reasoning=reasoning,
    )
    self.audit_logger.log_routing_decision(decision)

    return response

stream_complete(messages, tools=None, temperature=None) async

Stream a completion with privacy routing.

For stream mode, sanitization reconstruction is not supported (placeholders would need to be detected mid-stream). Falls back to local if sanitization would be needed.

Parameters:

Name Type Description Default
messages list[Message]

Conversation history

required
tools list[dict[str, Any]] | None

Available tools in OpenAI function format

None
temperature float | None

Sampling temperature override

None

Yields:

Type Description
AsyncIterator[str]

Content chunks as strings

Source code in src/harombe/privacy/router.py
async def stream_complete(
    self,
    messages: list[Message],
    tools: list[dict[str, Any]] | None = None,
    temperature: float | None = None,
) -> AsyncIterator[str]:
    """Stream a completion with privacy routing.

    For stream mode, sanitization reconstruction is not supported
    (placeholders would need to be detected mid-stream). Falls back
    to local if sanitization would be needed.

    Args:
        messages: Conversation history
        tools: Available tools in OpenAI function format
        temperature: Sampling temperature override

    Yields:
        Content chunks as strings
    """
    query = self._extract_latest_query(messages)
    sensitivity = self.classifier.classify(query, messages)
    target = self._get_routing_target(sensitivity.level)

    # For streaming, sanitized cloud is downgraded to local
    # (can't reconstruct placeholders mid-stream)
    if target == RoutingTarget.CLOUD_SANITIZED:
        target = RoutingTarget.LOCAL

    if target == RoutingTarget.CLOUD:
        async for chunk in self.cloud_client.stream_complete(messages, tools, temperature):
            yield chunk
    else:
        async for chunk in self.local_client.stream_complete(messages, tools, temperature):
            yield chunk

get_stats()

Get routing statistics.

Returns:

Type Description
dict[str, Any]

Dict with routing counts and current mode

Source code in src/harombe/privacy/router.py
def get_stats(self) -> dict[str, Any]:
    """Get routing statistics.

    Returns:
        Dict with routing counts and current mode
    """
    total = sum(self.routing_stats.values())
    return {
        "mode": self.mode.value,
        "total_requests": total,
        "local_count": self.routing_stats.get("local", 0),
        "cloud_count": self.routing_stats.get("cloud", 0),
        "cloud_sanitized_count": self.routing_stats.get("cloud_sanitized", 0),
    }

ContextSanitizer

Sanitizes messages by replacing sensitive entities with placeholders.

Source code in src/harombe/privacy/sanitizer.py
class ContextSanitizer:
    """Sanitizes messages by replacing sensitive entities with placeholders."""

    # Placeholder format: [TYPE_N] e.g. [EMAIL_1], [SSN_2]
    TYPE_LABELS: ClassVar[dict[str, str]] = {
        "email": "EMAIL",
        "ssn": "SSN",
        "phone": "PHONE",
        "ip_address": "IP",
        "credit_card": "CARD",
        "date_of_birth": "DOB",
        "address": "ADDR",
    }

    def __init__(self) -> None:
        self._value_to_placeholder: dict[str, str] = {}
        self._type_counters: dict[str, int] = {}

    def _get_placeholder(self, entity: PIIEntity) -> str:
        """Get or create a consistent placeholder for an entity value.

        Same value always maps to the same placeholder within a session.

        Args:
            entity: The PII entity to create a placeholder for

        Returns:
            Placeholder string like "[EMAIL_1]"
        """
        if entity.value in self._value_to_placeholder:
            return self._value_to_placeholder[entity.value]

        # Determine type label
        base_type = entity.type.split(":")[-1]  # Handle "credential:api_key" -> "api_key"
        label = self.TYPE_LABELS.get(base_type, base_type.upper())

        # Increment counter for this type
        self._type_counters[label] = self._type_counters.get(label, 0) + 1
        placeholder = f"[{label}_{self._type_counters[label]}]"

        self._value_to_placeholder[entity.value] = placeholder
        return placeholder

    def sanitize_messages(
        self,
        messages: list[Message],
        entities: list[PIIEntity],
    ) -> tuple[list[Message], SanitizationMap]:
        """Sanitize a list of messages by replacing detected entities.

        Args:
            messages: Conversation messages to sanitize
            entities: PII entities detected by the classifier

        Returns:
            Tuple of (sanitized messages, sanitization map for reconstruction)
        """
        san_map = SanitizationMap()

        # Build placeholder map from entities
        for entity in entities:
            placeholder = self._get_placeholder(entity)
            san_map.add(placeholder, entity.value)

        # Sanitize each message
        sanitized = []
        for msg in messages:
            new_content = self._replace_entities(msg.content, san_map)
            sanitized.append(
                Message(
                    role=msg.role,
                    content=new_content,
                    tool_calls=msg.tool_calls,
                    tool_call_id=msg.tool_call_id,
                    name=msg.name,
                )
            )

        return sanitized, san_map

    def _replace_entities(self, text: str, san_map: SanitizationMap) -> str:
        """Replace all known entity values in text with placeholders.

        Args:
            text: Text to sanitize
            san_map: Mapping of placeholders to original values

        Returns:
            Sanitized text
        """
        result = text
        # Sort by value length descending to avoid partial replacements
        for placeholder, original in sorted(
            san_map.replacements.items(),
            key=lambda item: len(item[1]),
            reverse=True,
        ):
            result = result.replace(original, placeholder)
        return result

    def reconstruct_response(
        self,
        response: CompletionResponse,
        san_map: SanitizationMap,
    ) -> CompletionResponse:
        """Restore original values in a cloud LLM response.

        Args:
            response: Cloud LLM response with placeholders
            san_map: Sanitization map from sanitize_messages()

        Returns:
            Response with placeholders replaced by original values
        """
        content = response.content
        for placeholder, original in san_map.replacements.items():
            content = content.replace(placeholder, original)

        return CompletionResponse(
            content=content,
            tool_calls=response.tool_calls,
            finish_reason=response.finish_reason,
        )

    def reset(self) -> None:
        """Reset placeholder state for a new conversation."""
        self._value_to_placeholder.clear()
        self._type_counters.clear()

sanitize_messages(messages, entities)

Sanitize a list of messages by replacing detected entities.

Parameters:

Name Type Description Default
messages list[Message]

Conversation messages to sanitize

required
entities list[PIIEntity]

PII entities detected by the classifier

required

Returns:

Type Description
tuple[list[Message], SanitizationMap]

Tuple of (sanitized messages, sanitization map for reconstruction)

Source code in src/harombe/privacy/sanitizer.py
def sanitize_messages(
    self,
    messages: list[Message],
    entities: list[PIIEntity],
) -> tuple[list[Message], SanitizationMap]:
    """Sanitize a list of messages by replacing detected entities.

    Args:
        messages: Conversation messages to sanitize
        entities: PII entities detected by the classifier

    Returns:
        Tuple of (sanitized messages, sanitization map for reconstruction)
    """
    san_map = SanitizationMap()

    # Build placeholder map from entities
    for entity in entities:
        placeholder = self._get_placeholder(entity)
        san_map.add(placeholder, entity.value)

    # Sanitize each message
    sanitized = []
    for msg in messages:
        new_content = self._replace_entities(msg.content, san_map)
        sanitized.append(
            Message(
                role=msg.role,
                content=new_content,
                tool_calls=msg.tool_calls,
                tool_call_id=msg.tool_call_id,
                name=msg.name,
            )
        )

    return sanitized, san_map

reconstruct_response(response, san_map)

Restore original values in a cloud LLM response.

Parameters:

Name Type Description Default
response CompletionResponse

Cloud LLM response with placeholders

required
san_map SanitizationMap

Sanitization map from sanitize_messages()

required

Returns:

Type Description
CompletionResponse

Response with placeholders replaced by original values

Source code in src/harombe/privacy/sanitizer.py
def reconstruct_response(
    self,
    response: CompletionResponse,
    san_map: SanitizationMap,
) -> CompletionResponse:
    """Restore original values in a cloud LLM response.

    Args:
        response: Cloud LLM response with placeholders
        san_map: Sanitization map from sanitize_messages()

    Returns:
        Response with placeholders replaced by original values
    """
    content = response.content
    for placeholder, original in san_map.replacements.items():
        content = content.replace(placeholder, original)

    return CompletionResponse(
        content=content,
        tool_calls=response.tool_calls,
        finish_reason=response.finish_reason,
    )

reset()

Reset placeholder state for a new conversation.

Source code in src/harombe/privacy/sanitizer.py
def reset(self) -> None:
    """Reset placeholder state for a new conversation."""
    self._value_to_placeholder.clear()
    self._type_counters.clear()

create_privacy_router(config)

Factory function that creates the appropriate LLM client.

If mode is "local-only" (the default), returns a raw OllamaClient with zero overhead. Otherwise wraps OllamaClient + AnthropicClient in a PrivacyRouter.

Parameters:

Name Type Description Default
config HarombeConfig

HarombeConfig instance

required

Returns:

Type Description
OllamaClient | PrivacyRouter

An LLM client (OllamaClient or PrivacyRouter)

Source code in src/harombe/privacy/router.py
def create_privacy_router(config: "HarombeConfig") -> OllamaClient | PrivacyRouter:
    """Factory function that creates the appropriate LLM client.

    If mode is "local-only" (the default), returns a raw OllamaClient
    with zero overhead. Otherwise wraps OllamaClient + AnthropicClient
    in a PrivacyRouter.

    Args:
        config: HarombeConfig instance

    Returns:
        An LLM client (OllamaClient or PrivacyRouter)
    """
    local_client = OllamaClient(
        model=config.model.name,
        base_url=config.ollama.host + "/v1",
        timeout=config.ollama.timeout,
        temperature=config.model.temperature,
    )

    privacy_config = config.privacy

    if privacy_config.mode == RoutingMode.LOCAL_ONLY:
        return local_client

    # Get API key from environment
    api_key_env = privacy_config.cloud_llm.api_key_env
    api_key = os.environ.get(api_key_env, "")
    if not api_key:
        logger.warning(
            "Cloud LLM API key not found in %s, falling back to local-only mode",
            api_key_env,
        )
        return local_client

    cloud_client = AnthropicClient(
        api_key=api_key,
        model=privacy_config.cloud_llm.model,
        max_tokens=privacy_config.cloud_llm.max_tokens,
        timeout=privacy_config.cloud_llm.timeout,
        temperature=config.model.temperature,
    )

    classifier = SensitivityClassifier(
        custom_patterns=privacy_config.custom_patterns or None,
        custom_restricted_keywords=privacy_config.custom_restricted_keywords or None,
    )

    audit_logger = None
    if privacy_config.audit_routing and config.security.audit.enabled:
        from harombe.security.audit_logger import AuditLogger

        audit_logger = PrivacyAuditLogger(
            AuditLogger(
                db_path=config.security.audit.database,
                retention_days=config.security.audit.retention_days,
                redact_sensitive=config.security.audit.redact_sensitive,
            )
        )
    else:
        audit_logger = PrivacyAuditLogger()

    return PrivacyRouter(
        local_client=local_client,
        cloud_client=cloud_client,
        mode=RoutingMode(privacy_config.mode),
        classifier=classifier,
        audit_logger=audit_logger,
        reconstruct_responses=privacy_config.reconstruct_responses,
    )

options: show_root_heading: true members_order: source

Tools

Built-in tool implementations: shell, filesystem, web search, browser.

harombe.tools

Built-in tool implementations for harombe agents.

Tools are registered via the @tool decorator and provide agents with capabilities like shell execution, file operations, web search, and browser automation. Tools declare whether they are dangerous (requiring user confirmation) and expose JSON Schema for LLM function calling.

Available tools:

  • shell - Execute shell commands (dangerous)
  • read_file / write_file - Filesystem operations
  • web_search - DuckDuckGo search (no API key required)
  • browser - Playwright-based browser automation (Phase 4.6)

Usage::

from harombe.tools.registry import get_enabled_tools

tools = get_enabled_tools(shell=True, filesystem=True, web_search=True)

options: show_root_heading: true members_order: source

Voice

Speech-to-text (Whisper) and text-to-speech (Piper, Coqui) capabilities.

harombe.voice

Voice capabilities for harombe (STT, TTS, VAD, voice client).

Provides speech-to-text via Whisper (tiny to large-v3 models) and text-to-speech via Piper (fast, all Python versions) or Coqui (high-quality, Python <3.11 only). Includes energy-based Voice Activity Detection (VAD) for hands-free operation. Supports push-to-talk voice interaction and REST/WebSocket streaming APIs.

Components:

  • :class:WhisperSTT - Speech-to-text with faster-whisper
  • :class:PiperTTS - Fast text-to-speech engine
  • :class:CoquiTTS - High-quality text-to-speech (Python <3.11)
  • :class:VoiceActivityDetector - Energy-based VAD for speech boundary detection

CoquiTTS

Bases: TTSEngine

Coqui TTS-based text-to-speech engine.

Coqui TTS provides high-quality speech synthesis with support for multiple languages, voices, and even voice cloning.

Source code in src/harombe/voice/coqui.py
class CoquiTTS(TTSEngine):
    """Coqui TTS-based text-to-speech engine.

    Coqui TTS provides high-quality speech synthesis with support for
    multiple languages, voices, and even voice cloning.
    """

    def __init__(
        self,
        model_name: str = "tts_models/en/ljspeech/tacotron2-DDC",
        device: Literal["cpu", "cuda"] = "cpu",
        vocoder: str | None = None,
    ):
        """Initialize Coqui TTS engine.

        Args:
            model_name: Coqui TTS model path
            device: Device to run inference on
            vocoder: Optional vocoder model (auto-selected if None)
        """
        self._model_name = model_name
        self._device = device
        self._vocoder = vocoder
        self._tts: Any = None
        self._sample_rate = 22050  # Default, updated after model load

    def _load_model(self) -> None:
        """Lazy load the Coqui TTS model."""
        if self._tts is not None:
            return

        try:
            from TTS.api import TTS  # type: ignore[import-not-found]
        except ImportError as e:
            msg = (
                "Coqui TTS not installed. "
                "Note: Coqui TTS only supports Python <3.11. "
                "Install with: pip install 'harombe[coqui]' (Python 3.10 or earlier) "
                "or use Piper TTS instead (supports all Python versions)."
            )
            raise ImportError(msg) from e

        logger.info(f"Loading Coqui TTS model: {self._model_name}")

        try:
            # Initialize TTS
            self._tts = TTS(
                model_name=self._model_name,
                vocoder_name=self._vocoder,
                progress_bar=False,
                gpu=(self._device == "cuda"),
            )

            # Get sample rate from model config
            if hasattr(self._tts.synthesizer, "output_sample_rate"):
                self._sample_rate = self._tts.synthesizer.output_sample_rate
            elif hasattr(self._tts, "config"):
                self._sample_rate = getattr(self._tts.config, "audio", {}).get("sample_rate", 22050)

            logger.info(f"Coqui TTS loaded successfully (sample_rate={self._sample_rate})")

        except Exception as e:
            logger.error(f"Failed to load Coqui TTS model: {e}")
            raise

    async def synthesize(
        self,
        text: str,
        voice: str = "default",
        speed: float = 1.0,
    ) -> bytes:
        """Convert text to audio.

        Args:
            text: Text to convert to speech
            voice: Speaker name (if multi-speaker model)
            speed: Speech speed multiplier (0.5-2.0)

        Returns:
            Audio data in WAV format
        """
        self._load_model()

        if not text.strip():
            return self._create_empty_wav()

        # Run synthesis in thread pool
        loop = asyncio.get_event_loop()
        audio_samples = await loop.run_in_executor(
            None,
            lambda: self._synthesize_sync(text, voice, speed),
        )

        # Convert to WAV format
        return self._samples_to_wav(audio_samples)

    def _synthesize_sync(
        self,
        text: str,
        voice: str,
        speed: float,
    ) -> list[float]:
        """Synchronous synthesis (runs in thread pool)."""
        import numpy as np  # type: ignore[import-not-found]

        # Prepare kwargs
        kwargs = {}
        if voice != "default" and self._tts.is_multi_speaker:
            kwargs["speaker"] = voice

        # Generate audio
        wav = self._tts.tts(text, **kwargs)

        # Adjust speed if needed
        if speed != 1.0:
            wav = self._adjust_speed(np.array(wav), speed)

        return wav if isinstance(wav, list) else wav.tolist()

    def _adjust_speed(self, audio: Any, speed: float) -> Any:
        """Adjust audio speed via resampling."""
        import numpy as np

        if speed == 1.0:
            return audio

        audio_array = np.array(audio, dtype=np.float32)
        target_length = int(len(audio_array) / speed)
        indices = np.linspace(0, len(audio_array) - 1, target_length)
        resampled = np.interp(indices, np.arange(len(audio_array)), audio_array)

        return resampled

    def _samples_to_wav(self, samples: list[float]) -> bytes:
        """Convert audio samples to WAV format."""
        import numpy as np

        # Convert to 16-bit PCM
        audio_array = np.array(samples, dtype=np.float32)
        audio_int16 = np.clip(audio_array * 32767, -32768, 32767).astype(np.int16)

        # Create WAV header
        num_samples = len(audio_int16)
        byte_rate = self._sample_rate * 2
        data_size = num_samples * 2

        wav_header = struct.pack(
            "<4sI4s4sIHHIIHH4sI",
            b"RIFF",
            36 + data_size,
            b"WAVE",
            b"fmt ",
            16,
            1,
            1,
            self._sample_rate,
            byte_rate,
            2,
            16,
            b"data",
            data_size,
        )

        result: bytes = wav_header + audio_int16.tobytes()
        return result

    def _create_empty_wav(self) -> bytes:
        """Create empty WAV file."""
        wav_header = struct.pack(
            "<4sI4s4sIHHIIHH4sI",
            b"RIFF",
            36,
            b"WAVE",
            b"fmt ",
            16,
            1,
            1,
            self._sample_rate,
            self._sample_rate * 2,
            2,
            16,
            b"data",
            0,
        )
        return wav_header

    async def synthesize_stream(
        self,
        text: str,
        voice: str = "default",
        speed: float = 1.0,
    ) -> AsyncIterator[bytes]:
        """Stream audio generation.

        Note: Coqui TTS doesn't have native streaming support, so this
        generates the full audio then yields it in chunks.

        Args:
            text: Text to convert to speech
            voice: Speaker name (if multi-speaker model)
            speed: Speech speed multiplier

        Yields:
            Audio chunks
        """
        # Generate full audio
        audio = await self.synthesize(text, voice, speed)

        # Yield WAV header
        header_size = 44
        yield audio[:header_size]

        # Yield audio data in chunks
        chunk_size = 4096
        data = audio[header_size:]

        for i in range(0, len(data), chunk_size):
            yield data[i : i + chunk_size]
            await asyncio.sleep(0)  # Allow other tasks to run

    @property
    def available_voices(self) -> list[str]:
        """Return list of available voice names."""
        self._load_model()

        if self._tts.is_multi_speaker:
            return self._tts.speakers or []

        return ["default"]

    @property
    def sample_rate(self) -> int:
        """Return the sample rate of generated audio."""
        return self._sample_rate

    @property
    def engine_name(self) -> str:
        """Return the name of the TTS engine."""
        model_short = self._model_name.split("/")[-1]
        return f"coqui-{model_short}"

available_voices property

Return list of available voice names.

sample_rate property

Return the sample rate of generated audio.

engine_name property

Return the name of the TTS engine.

__init__(model_name='tts_models/en/ljspeech/tacotron2-DDC', device='cpu', vocoder=None)

Initialize Coqui TTS engine.

Parameters:

Name Type Description Default
model_name str

Coqui TTS model path

'tts_models/en/ljspeech/tacotron2-DDC'
device Literal['cpu', 'cuda']

Device to run inference on

'cpu'
vocoder str | None

Optional vocoder model (auto-selected if None)

None
Source code in src/harombe/voice/coqui.py
def __init__(
    self,
    model_name: str = "tts_models/en/ljspeech/tacotron2-DDC",
    device: Literal["cpu", "cuda"] = "cpu",
    vocoder: str | None = None,
):
    """Initialize Coqui TTS engine.

    Args:
        model_name: Coqui TTS model path
        device: Device to run inference on
        vocoder: Optional vocoder model (auto-selected if None)
    """
    self._model_name = model_name
    self._device = device
    self._vocoder = vocoder
    self._tts: Any = None
    self._sample_rate = 22050  # Default, updated after model load

synthesize(text, voice='default', speed=1.0) async

Convert text to audio.

Parameters:

Name Type Description Default
text str

Text to convert to speech

required
voice str

Speaker name (if multi-speaker model)

'default'
speed float

Speech speed multiplier (0.5-2.0)

1.0

Returns:

Type Description
bytes

Audio data in WAV format

Source code in src/harombe/voice/coqui.py
async def synthesize(
    self,
    text: str,
    voice: str = "default",
    speed: float = 1.0,
) -> bytes:
    """Convert text to audio.

    Args:
        text: Text to convert to speech
        voice: Speaker name (if multi-speaker model)
        speed: Speech speed multiplier (0.5-2.0)

    Returns:
        Audio data in WAV format
    """
    self._load_model()

    if not text.strip():
        return self._create_empty_wav()

    # Run synthesis in thread pool
    loop = asyncio.get_event_loop()
    audio_samples = await loop.run_in_executor(
        None,
        lambda: self._synthesize_sync(text, voice, speed),
    )

    # Convert to WAV format
    return self._samples_to_wav(audio_samples)

synthesize_stream(text, voice='default', speed=1.0) async

Stream audio generation.

Note: Coqui TTS doesn't have native streaming support, so this generates the full audio then yields it in chunks.

Parameters:

Name Type Description Default
text str

Text to convert to speech

required
voice str

Speaker name (if multi-speaker model)

'default'
speed float

Speech speed multiplier

1.0

Yields:

Type Description
AsyncIterator[bytes]

Audio chunks

Source code in src/harombe/voice/coqui.py
async def synthesize_stream(
    self,
    text: str,
    voice: str = "default",
    speed: float = 1.0,
) -> AsyncIterator[bytes]:
    """Stream audio generation.

    Note: Coqui TTS doesn't have native streaming support, so this
    generates the full audio then yields it in chunks.

    Args:
        text: Text to convert to speech
        voice: Speaker name (if multi-speaker model)
        speed: Speech speed multiplier

    Yields:
        Audio chunks
    """
    # Generate full audio
    audio = await self.synthesize(text, voice, speed)

    # Yield WAV header
    header_size = 44
    yield audio[:header_size]

    # Yield audio data in chunks
    chunk_size = 4096
    data = audio[header_size:]

    for i in range(0, len(data), chunk_size):
        yield data[i : i + chunk_size]
        await asyncio.sleep(0)  # Allow other tasks to run

PiperTTS

Bases: TTSEngine

Piper-based text-to-speech engine.

Piper is a fast, local TTS system that uses ONNX models for inference. It provides good quality with very low latency (100-300ms for short phrases).

Source code in src/harombe/voice/piper.py
class PiperTTS(TTSEngine):
    """Piper-based text-to-speech engine.

    Piper is a fast, local TTS system that uses ONNX models for inference.
    It provides good quality with very low latency (100-300ms for short phrases).
    """

    def __init__(
        self,
        model: str = "en_US-lessac-medium",
        device: Literal["cpu", "cuda"] = "cpu",
        download_root: str | Path | None = None,
    ):
        """Initialize Piper TTS engine.

        Args:
            model: Piper model name (e.g., "en_US-lessac-medium")
            device: Device to run inference on (Piper uses ONNX, limited GPU support)
            download_root: Directory to download models to
        """
        self._model_name = model
        self._device = device
        self._download_root = download_root
        self._piper_instance: Any = None
        self._sample_rate = 22050  # Piper default

    def _load_model(self) -> None:
        """Lazy load the Piper model."""
        if self._piper_instance is not None:
            return

        try:
            from piper.voice import PiperVoice  # type: ignore[import-not-found]
        except ImportError as e:
            msg = "piper-tts not installed. Install with: " "pip install piper-tts"
            raise ImportError(msg) from e

        logger.info(f"Loading Piper TTS model: {self._model_name}")

        # Download and load model
        try:
            self._piper_instance = PiperVoice.load(
                self._model_name,
                download_dir=str(self._download_root) if self._download_root else None,
                use_cuda=(self._device == "cuda"),
            )
            self._sample_rate = self._piper_instance.config.sample_rate

            logger.info(f"Piper model loaded successfully (sample_rate={self._sample_rate})")
        except Exception as e:
            logger.error(f"Failed to load Piper model {self._model_name}: {e}")
            raise

    async def synthesize(
        self,
        text: str,
        voice: str = "default",
        speed: float = 1.0,
    ) -> bytes:
        """Convert text to audio.

        Args:
            text: Text to convert to speech
            voice: Unused for Piper (model determines voice)
            speed: Speech speed multiplier (0.5-2.0)

        Returns:
            Audio data in WAV format
        """
        self._load_model()

        if not text.strip():
            return self._create_empty_wav()

        # Run synthesis in thread pool
        loop = asyncio.get_event_loop()
        audio_samples = await loop.run_in_executor(
            None,
            lambda: self._synthesize_sync(text, speed),
        )

        # Convert to WAV format
        return self._samples_to_wav(audio_samples)

    def _synthesize_sync(self, text: str, speed: float) -> list[int]:
        """Synchronous synthesis (runs in thread pool)."""

        # Piper generates audio samples
        audio_stream = self._piper_instance.synthesize_stream_raw(text)

        # Collect all audio samples
        audio_samples = []
        for audio_chunk in audio_stream:
            # Adjust speed if needed
            if speed != 1.0:
                # Simple speed adjustment (more sophisticated methods exist)
                audio_chunk = self._adjust_speed(audio_chunk, speed)

            audio_samples.extend(audio_chunk)

        return audio_samples

    def _adjust_speed(self, audio: Any, speed: float) -> Any:
        """Adjust audio speed (simple resampling)."""
        import numpy as np  # type: ignore[import-not-found]

        if speed == 1.0:
            return audio

        # Convert to numpy array if needed
        audio_array = np.array(audio, dtype=np.float32)

        # Simple speed adjustment via resampling
        target_length = int(len(audio_array) / speed)
        indices = np.linspace(0, len(audio_array) - 1, target_length)
        resampled = np.interp(indices, np.arange(len(audio_array)), audio_array)

        return resampled.tolist()

    def _samples_to_wav(self, samples: list[int]) -> bytes:
        """Convert audio samples to WAV format."""
        import numpy as np

        # Convert to 16-bit PCM
        audio_array = np.array(samples, dtype=np.float32)
        audio_int16 = np.clip(audio_array * 32767, -32768, 32767).astype(np.int16)

        # Create WAV header
        num_samples = len(audio_int16)
        byte_rate = self._sample_rate * 2  # 16-bit mono
        data_size = num_samples * 2

        wav_header = struct.pack(
            "<4sI4s4sIHHIIHH4sI",
            b"RIFF",
            36 + data_size,
            b"WAVE",
            b"fmt ",
            16,  # PCM format chunk size
            1,  # PCM format
            1,  # Mono
            self._sample_rate,
            byte_rate,
            2,  # Block align
            16,  # Bits per sample
            b"data",
            data_size,
        )

        # Combine header and audio data
        result: bytes = wav_header + audio_int16.tobytes()
        return result

    def _create_empty_wav(self) -> bytes:
        """Create empty WAV file for empty text."""
        wav_header = struct.pack(
            "<4sI4s4sIHHIIHH4sI",
            b"RIFF",
            36,  # No data
            b"WAVE",
            b"fmt ",
            16,
            1,
            1,
            self._sample_rate,
            self._sample_rate * 2,
            2,
            16,
            b"data",
            0,  # No data
        )
        return wav_header

    async def synthesize_stream(
        self,
        text: str,
        voice: str = "default",
        speed: float = 1.0,
    ) -> AsyncIterator[bytes]:
        """Stream audio generation with sentence-level chunking.

        Splits text into sentences and synthesizes each independently,
        yielding audio as soon as each sentence is ready. This reduces
        time-to-first-audio compared to synthesizing the full text.

        Args:
            text: Text to convert to speech
            voice: Unused for Piper (model determines voice)
            speed: Speech speed multiplier

        Yields:
            Audio chunks in WAV format (header first, then PCM data)
        """
        self._load_model()

        if not text.strip():
            yield self._create_empty_wav()
            return

        # Yield WAV header first (estimated size)
        max_samples = len(text) * 1000
        wav_header = struct.pack(
            "<4sI4s4sIHHIIHH4sI",
            b"RIFF",
            36 + max_samples * 2,
            b"WAVE",
            b"fmt ",
            16,
            1,
            1,
            self._sample_rate,
            self._sample_rate * 2,
            2,
            16,
            b"data",
            max_samples * 2,
        )
        yield wav_header

        loop = asyncio.get_event_loop()

        # Split into sentences for incremental synthesis
        sentences = _split_sentences(text)

        for sentence in sentences:
            if not sentence.strip():
                continue

            def synthesize_sentence(s: str = sentence) -> bytes:
                import numpy as np

                samples: list[int] = []
                for audio_chunk in self._piper_instance.synthesize_stream_raw(s):
                    if speed != 1.0:
                        audio_chunk = self._adjust_speed(audio_chunk, speed)
                    samples.extend(audio_chunk)

                audio_array = np.array(samples, dtype=np.float32)
                audio_int16 = np.clip(audio_array * 32767, -32768, 32767).astype(np.int16)
                return audio_int16.tobytes()

            chunk = await loop.run_in_executor(None, synthesize_sentence)
            yield chunk

    @property
    def available_voices(self) -> list[str]:
        """Return list of available voice names.

        Note: Piper models are voice-specific, so this returns the current model.
        """
        return [self._model_name]

    @property
    def sample_rate(self) -> int:
        """Return the sample rate of generated audio."""
        return self._sample_rate

    @property
    def engine_name(self) -> str:
        """Return the name of the TTS engine."""
        return f"piper-{self._model_name}"

available_voices property

Return list of available voice names.

Note: Piper models are voice-specific, so this returns the current model.

sample_rate property

Return the sample rate of generated audio.

engine_name property

Return the name of the TTS engine.

__init__(model='en_US-lessac-medium', device='cpu', download_root=None)

Initialize Piper TTS engine.

Parameters:

Name Type Description Default
model str

Piper model name (e.g., "en_US-lessac-medium")

'en_US-lessac-medium'
device Literal['cpu', 'cuda']

Device to run inference on (Piper uses ONNX, limited GPU support)

'cpu'
download_root str | Path | None

Directory to download models to

None
Source code in src/harombe/voice/piper.py
def __init__(
    self,
    model: str = "en_US-lessac-medium",
    device: Literal["cpu", "cuda"] = "cpu",
    download_root: str | Path | None = None,
):
    """Initialize Piper TTS engine.

    Args:
        model: Piper model name (e.g., "en_US-lessac-medium")
        device: Device to run inference on (Piper uses ONNX, limited GPU support)
        download_root: Directory to download models to
    """
    self._model_name = model
    self._device = device
    self._download_root = download_root
    self._piper_instance: Any = None
    self._sample_rate = 22050  # Piper default

synthesize(text, voice='default', speed=1.0) async

Convert text to audio.

Parameters:

Name Type Description Default
text str

Text to convert to speech

required
voice str

Unused for Piper (model determines voice)

'default'
speed float

Speech speed multiplier (0.5-2.0)

1.0

Returns:

Type Description
bytes

Audio data in WAV format

Source code in src/harombe/voice/piper.py
async def synthesize(
    self,
    text: str,
    voice: str = "default",
    speed: float = 1.0,
) -> bytes:
    """Convert text to audio.

    Args:
        text: Text to convert to speech
        voice: Unused for Piper (model determines voice)
        speed: Speech speed multiplier (0.5-2.0)

    Returns:
        Audio data in WAV format
    """
    self._load_model()

    if not text.strip():
        return self._create_empty_wav()

    # Run synthesis in thread pool
    loop = asyncio.get_event_loop()
    audio_samples = await loop.run_in_executor(
        None,
        lambda: self._synthesize_sync(text, speed),
    )

    # Convert to WAV format
    return self._samples_to_wav(audio_samples)

synthesize_stream(text, voice='default', speed=1.0) async

Stream audio generation with sentence-level chunking.

Splits text into sentences and synthesizes each independently, yielding audio as soon as each sentence is ready. This reduces time-to-first-audio compared to synthesizing the full text.

Parameters:

Name Type Description Default
text str

Text to convert to speech

required
voice str

Unused for Piper (model determines voice)

'default'
speed float

Speech speed multiplier

1.0

Yields:

Type Description
AsyncIterator[bytes]

Audio chunks in WAV format (header first, then PCM data)

Source code in src/harombe/voice/piper.py
async def synthesize_stream(
    self,
    text: str,
    voice: str = "default",
    speed: float = 1.0,
) -> AsyncIterator[bytes]:
    """Stream audio generation with sentence-level chunking.

    Splits text into sentences and synthesizes each independently,
    yielding audio as soon as each sentence is ready. This reduces
    time-to-first-audio compared to synthesizing the full text.

    Args:
        text: Text to convert to speech
        voice: Unused for Piper (model determines voice)
        speed: Speech speed multiplier

    Yields:
        Audio chunks in WAV format (header first, then PCM data)
    """
    self._load_model()

    if not text.strip():
        yield self._create_empty_wav()
        return

    # Yield WAV header first (estimated size)
    max_samples = len(text) * 1000
    wav_header = struct.pack(
        "<4sI4s4sIHHIIHH4sI",
        b"RIFF",
        36 + max_samples * 2,
        b"WAVE",
        b"fmt ",
        16,
        1,
        1,
        self._sample_rate,
        self._sample_rate * 2,
        2,
        16,
        b"data",
        max_samples * 2,
    )
    yield wav_header

    loop = asyncio.get_event_loop()

    # Split into sentences for incremental synthesis
    sentences = _split_sentences(text)

    for sentence in sentences:
        if not sentence.strip():
            continue

        def synthesize_sentence(s: str = sentence) -> bytes:
            import numpy as np

            samples: list[int] = []
            for audio_chunk in self._piper_instance.synthesize_stream_raw(s):
                if speed != 1.0:
                    audio_chunk = self._adjust_speed(audio_chunk, speed)
                samples.extend(audio_chunk)

            audio_array = np.array(samples, dtype=np.float32)
            audio_int16 = np.clip(audio_array * 32767, -32768, 32767).astype(np.int16)
            return audio_int16.tobytes()

        chunk = await loop.run_in_executor(None, synthesize_sentence)
        yield chunk

STTEngine

Bases: Protocol

Protocol for speech-to-text engines.

Source code in src/harombe/voice/stt.py
class STTEngine(Protocol):
    """Protocol for speech-to-text engines."""

    async def transcribe(
        self,
        audio: bytes,
        language: str | None = None,
    ) -> TranscriptionResult:
        """Transcribe audio to text.

        Args:
            audio: Audio data in WAV format (16kHz, mono)
            language: Optional language code (e.g., "en", "es"). None for auto-detect.

        Returns:
            Transcription result with text and metadata
        """
        ...

    def transcribe_stream(
        self,
        audio_stream: AsyncIterator[bytes],
    ) -> AsyncIterator[str]:
        """Stream transcription in real-time.

        Args:
            audio_stream: Async iterator of audio chunks

        Yields:
            Partial transcription results as they become available
        """
        ...

    @property
    def model_name(self) -> str:
        """Return the name of the STT model being used."""
        ...

model_name property

Return the name of the STT model being used.

transcribe(audio, language=None) async

Transcribe audio to text.

Parameters:

Name Type Description Default
audio bytes

Audio data in WAV format (16kHz, mono)

required
language str | None

Optional language code (e.g., "en", "es"). None for auto-detect.

None

Returns:

Type Description
TranscriptionResult

Transcription result with text and metadata

Source code in src/harombe/voice/stt.py
async def transcribe(
    self,
    audio: bytes,
    language: str | None = None,
) -> TranscriptionResult:
    """Transcribe audio to text.

    Args:
        audio: Audio data in WAV format (16kHz, mono)
        language: Optional language code (e.g., "en", "es"). None for auto-detect.

    Returns:
        Transcription result with text and metadata
    """
    ...

transcribe_stream(audio_stream)

Stream transcription in real-time.

Parameters:

Name Type Description Default
audio_stream AsyncIterator[bytes]

Async iterator of audio chunks

required

Yields:

Type Description
AsyncIterator[str]

Partial transcription results as they become available

Source code in src/harombe/voice/stt.py
def transcribe_stream(
    self,
    audio_stream: AsyncIterator[bytes],
) -> AsyncIterator[str]:
    """Stream transcription in real-time.

    Args:
        audio_stream: Async iterator of audio chunks

    Yields:
        Partial transcription results as they become available
    """
    ...

TranscriptionResult dataclass

Result of speech-to-text transcription.

Source code in src/harombe/voice/stt.py
@dataclass
class TranscriptionResult:
    """Result of speech-to-text transcription."""

    text: str
    language: str | None = None
    confidence: float | None = None
    segments: list[dict[str, float | str]] | None = None  # Word-level timestamps if available

TTSEngine

Bases: Protocol

Protocol for text-to-speech engines.

Source code in src/harombe/voice/tts.py
class TTSEngine(Protocol):
    """Protocol for text-to-speech engines."""

    async def synthesize(
        self,
        text: str,
        voice: str = "default",
        speed: float = 1.0,
    ) -> bytes:
        """Convert text to audio.

        Args:
            text: Text to convert to speech
            voice: Voice name or ID to use
            speed: Speech speed multiplier (0.5-2.0)

        Returns:
            Audio data in WAV format
        """
        ...

    def synthesize_stream(
        self,
        text: str,
        voice: str = "default",
        speed: float = 1.0,
    ) -> AsyncIterator[bytes]:
        """Stream audio generation.

        Args:
            text: Text to convert to speech
            voice: Voice name or ID to use
            speed: Speech speed multiplier (0.5-2.0)

        Yields:
            Audio chunks as they are generated
        """
        ...

    @property
    def available_voices(self) -> list[str]:
        """Return list of available voice names."""
        ...

    @property
    def sample_rate(self) -> int:
        """Return the sample rate of generated audio."""
        ...

    @property
    def engine_name(self) -> str:
        """Return the name of the TTS engine."""
        ...

available_voices property

Return list of available voice names.

sample_rate property

Return the sample rate of generated audio.

engine_name property

Return the name of the TTS engine.

synthesize(text, voice='default', speed=1.0) async

Convert text to audio.

Parameters:

Name Type Description Default
text str

Text to convert to speech

required
voice str

Voice name or ID to use

'default'
speed float

Speech speed multiplier (0.5-2.0)

1.0

Returns:

Type Description
bytes

Audio data in WAV format

Source code in src/harombe/voice/tts.py
async def synthesize(
    self,
    text: str,
    voice: str = "default",
    speed: float = 1.0,
) -> bytes:
    """Convert text to audio.

    Args:
        text: Text to convert to speech
        voice: Voice name or ID to use
        speed: Speech speed multiplier (0.5-2.0)

    Returns:
        Audio data in WAV format
    """
    ...

synthesize_stream(text, voice='default', speed=1.0)

Stream audio generation.

Parameters:

Name Type Description Default
text str

Text to convert to speech

required
voice str

Voice name or ID to use

'default'
speed float

Speech speed multiplier (0.5-2.0)

1.0

Yields:

Type Description
AsyncIterator[bytes]

Audio chunks as they are generated

Source code in src/harombe/voice/tts.py
def synthesize_stream(
    self,
    text: str,
    voice: str = "default",
    speed: float = 1.0,
) -> AsyncIterator[bytes]:
    """Stream audio generation.

    Args:
        text: Text to convert to speech
        voice: Voice name or ID to use
        speed: Speech speed multiplier (0.5-2.0)

    Yields:
        Audio chunks as they are generated
    """
    ...

VADConfig dataclass

Configuration for voice activity detection.

Attributes:

Name Type Description
energy_threshold float

RMS energy threshold for speech (0.0-1.0 normalized). Lower values are more sensitive. Default 0.01 works well for typical microphone input.

speech_pad_ms int

Milliseconds of silence to keep before speech start.

silence_duration_ms int

Milliseconds of silence before declaring speech end.

min_speech_duration_ms int

Minimum speech duration to emit (filters clicks/noise).

Source code in src/harombe/voice/vad.py
@dataclass
class VADConfig:
    """Configuration for voice activity detection.

    Attributes:
        energy_threshold: RMS energy threshold for speech (0.0-1.0 normalized).
            Lower values are more sensitive. Default 0.01 works well for
            typical microphone input.
        speech_pad_ms: Milliseconds of silence to keep before speech start.
        silence_duration_ms: Milliseconds of silence before declaring speech end.
        min_speech_duration_ms: Minimum speech duration to emit (filters clicks/noise).
    """

    energy_threshold: float = 0.01
    speech_pad_ms: int = 300
    silence_duration_ms: int = 800
    min_speech_duration_ms: int = 250

VADEvent dataclass

Event emitted by the voice activity detector.

Source code in src/harombe/voice/vad.py
@dataclass
class VADEvent:
    """Event emitted by the voice activity detector."""

    type: str  # "speech_start", "speech_end", "speech_audio"
    audio: bytes = b""
    duration_ms: int = 0

VADState

Bases: Enum

Current state of the voice activity detector.

Source code in src/harombe/voice/vad.py
class VADState(Enum):
    """Current state of the voice activity detector."""

    SILENCE = "silence"
    SPEECH = "speech"
    TRAILING = "trailing"  # Speech ended, waiting for silence timeout

VoiceActivityDetector

Energy-based voice activity detector.

Detects speech boundaries by monitoring RMS energy levels of audio frames. Emits events when speech starts/stops and passes through speech audio.

Usage::

vad = VoiceActivityDetector()
for event in vad.process_frame(audio_frame):
    if event.type == "speech_start":
        # User started speaking
        ...
    elif event.type == "speech_audio":
        # Audio data during speech
        ...
    elif event.type == "speech_end":
        # User stopped speaking, event.audio has complete utterance
        ...
Source code in src/harombe/voice/vad.py
class VoiceActivityDetector:
    """Energy-based voice activity detector.

    Detects speech boundaries by monitoring RMS energy levels of audio frames.
    Emits events when speech starts/stops and passes through speech audio.

    Usage::

        vad = VoiceActivityDetector()
        for event in vad.process_frame(audio_frame):
            if event.type == "speech_start":
                # User started speaking
                ...
            elif event.type == "speech_audio":
                # Audio data during speech
                ...
            elif event.type == "speech_end":
                # User stopped speaking, event.audio has complete utterance
                ...
    """

    def __init__(self, config: VADConfig | None = None) -> None:
        self._config = config or VADConfig()
        self._state = VADState.SILENCE
        self._speech_buffer: list[bytes] = []
        self._ring_buffer: list[bytes] = []
        self._silence_frames = 0
        self._speech_frames = 0

        # Pre-compute frame counts from ms config
        ms_per_frame = FRAME_DURATION_MS
        self._pad_frames = max(1, self._config.speech_pad_ms // ms_per_frame)
        self._silence_threshold_frames = max(1, self._config.silence_duration_ms // ms_per_frame)
        self._min_speech_frames = max(1, self._config.min_speech_duration_ms // ms_per_frame)

    @property
    def state(self) -> VADState:
        """Current VAD state."""
        return self._state

    def reset(self) -> None:
        """Reset detector state."""
        self._state = VADState.SILENCE
        self._speech_buffer.clear()
        self._ring_buffer.clear()
        self._silence_frames = 0
        self._speech_frames = 0

    def process_frame(self, frame: bytes) -> list[VADEvent]:
        """Process a single audio frame and return any events.

        Args:
            frame: Raw audio frame (16kHz, 16-bit mono PCM).
                Should be FRAME_BYTES (960) bytes for a 30ms frame.
                Larger frames are processed in FRAME_BYTES chunks.

        Returns:
            List of VADEvents (may be empty if no state change).
        """
        events: list[VADEvent] = []

        # Process in frame-sized chunks
        offset = 0
        while offset + FRAME_BYTES <= len(frame):
            chunk = frame[offset : offset + FRAME_BYTES]
            events.extend(self._process_single_frame(chunk))
            offset += FRAME_BYTES

        # Handle remainder (pad with zeros if needed for final partial frame)
        if offset < len(frame):
            remainder = frame[offset:]
            padded = remainder + b"\x00" * (FRAME_BYTES - len(remainder))
            events.extend(self._process_single_frame(padded))

        return events

    def _process_single_frame(self, frame: bytes) -> list[VADEvent]:
        """Process exactly one FRAME_BYTES frame."""
        events: list[VADEvent] = []
        is_speech = self._is_speech(frame)

        if self._state == VADState.SILENCE:
            # Keep a rolling buffer for pre-speech padding
            self._ring_buffer.append(frame)
            if len(self._ring_buffer) > self._pad_frames:
                self._ring_buffer.pop(0)

            if is_speech:
                self._state = VADState.SPEECH
                self._speech_frames = 1
                self._silence_frames = 0
                # Include pre-speech padding
                self._speech_buffer = list(self._ring_buffer)
                self._ring_buffer.clear()
                events.append(VADEvent(type="speech_start"))

        elif self._state == VADState.SPEECH:
            self._speech_buffer.append(frame)
            self._speech_frames += 1

            if is_speech:
                self._silence_frames = 0
                events.append(VADEvent(type="speech_audio", audio=frame))
            else:
                self._silence_frames += 1
                if self._silence_frames >= self._silence_threshold_frames:
                    self._state = VADState.SILENCE
                    # Only emit if speech was long enough
                    if self._speech_frames >= self._min_speech_frames:
                        complete_audio = b"".join(self._speech_buffer)
                        duration_ms = (
                            (len(complete_audio) // BYTES_PER_SAMPLE) * 1000 // SAMPLE_RATE
                        )
                        events.append(
                            VADEvent(
                                type="speech_end",
                                audio=complete_audio,
                                duration_ms=duration_ms,
                            )
                        )
                    self._speech_buffer.clear()
                    self._speech_frames = 0
                    self._silence_frames = 0
                else:
                    # Still in trailing silence, include in buffer
                    events.append(VADEvent(type="speech_audio", audio=frame))

        return events

    def _is_speech(self, frame: bytes) -> bool:
        """Check if a frame contains speech based on RMS energy."""
        # Unpack 16-bit samples
        n_samples = len(frame) // BYTES_PER_SAMPLE
        if n_samples == 0:
            return False

        samples = struct.unpack(f"<{n_samples}h", frame[: n_samples * BYTES_PER_SAMPLE])

        # Calculate RMS energy (normalized to 0.0-1.0)
        sum_sq = sum(s * s for s in samples)
        rms = (sum_sq / n_samples) ** 0.5 / 32768.0

        return rms >= self._config.energy_threshold

state property

Current VAD state.

reset()

Reset detector state.

Source code in src/harombe/voice/vad.py
def reset(self) -> None:
    """Reset detector state."""
    self._state = VADState.SILENCE
    self._speech_buffer.clear()
    self._ring_buffer.clear()
    self._silence_frames = 0
    self._speech_frames = 0

process_frame(frame)

Process a single audio frame and return any events.

Parameters:

Name Type Description Default
frame bytes

Raw audio frame (16kHz, 16-bit mono PCM). Should be FRAME_BYTES (960) bytes for a 30ms frame. Larger frames are processed in FRAME_BYTES chunks.

required

Returns:

Type Description
list[VADEvent]

List of VADEvents (may be empty if no state change).

Source code in src/harombe/voice/vad.py
def process_frame(self, frame: bytes) -> list[VADEvent]:
    """Process a single audio frame and return any events.

    Args:
        frame: Raw audio frame (16kHz, 16-bit mono PCM).
            Should be FRAME_BYTES (960) bytes for a 30ms frame.
            Larger frames are processed in FRAME_BYTES chunks.

    Returns:
        List of VADEvents (may be empty if no state change).
    """
    events: list[VADEvent] = []

    # Process in frame-sized chunks
    offset = 0
    while offset + FRAME_BYTES <= len(frame):
        chunk = frame[offset : offset + FRAME_BYTES]
        events.extend(self._process_single_frame(chunk))
        offset += FRAME_BYTES

    # Handle remainder (pad with zeros if needed for final partial frame)
    if offset < len(frame):
        remainder = frame[offset:]
        padded = remainder + b"\x00" * (FRAME_BYTES - len(remainder))
        events.extend(self._process_single_frame(padded))

    return events

WhisperSTT

Bases: STTEngine

Whisper-based speech-to-text engine using faster-whisper.

faster-whisper is a reimplementation of OpenAI's Whisper model using CTranslate2, which is up to 4x faster than the original implementation with lower memory usage.

Source code in src/harombe/voice/whisper.py
class WhisperSTT(STTEngine):
    """Whisper-based speech-to-text engine using faster-whisper.

    faster-whisper is a reimplementation of OpenAI's Whisper model using CTranslate2,
    which is up to 4x faster than the original implementation with lower memory usage.
    """

    def __init__(
        self,
        model_size: Literal["tiny", "base", "small", "medium", "large-v3"] = "medium",
        device: Literal["cpu", "cuda", "auto"] = "auto",
        compute_type: Literal["int8", "float16", "float32"] = "float16",
        download_root: str | Path | None = None,
    ):
        """Initialize Whisper STT engine.

        Args:
            model_size: Size of Whisper model to use
            device: Device to run inference on
            compute_type: Compute type for inference (lower = faster but less accurate)
            download_root: Directory to download models to (default: ~/.cache/huggingface)
        """
        self._model_size = model_size
        self._device = device
        self._compute_type = compute_type
        self._download_root = download_root
        self._model: Any = None

    def _load_model(self) -> None:
        """Lazy load the Whisper model."""
        if self._model is not None:
            return

        try:
            from faster_whisper import WhisperModel  # type: ignore[import-not-found]
        except ImportError as e:
            msg = "faster-whisper not installed. Install with: " "pip install faster-whisper"
            raise ImportError(msg) from e

        logger.info(
            f"Loading Whisper model: {self._model_size} "
            f"(device={self._device}, compute_type={self._compute_type})"
        )

        self._model = WhisperModel(
            self._model_size,
            device=self._device,
            compute_type=self._compute_type,
            download_root=self._download_root,
        )

        logger.info("Whisper model loaded successfully")

    async def transcribe(
        self,
        audio: bytes,
        language: str | None = None,
    ) -> TranscriptionResult:
        """Transcribe audio to text.

        Args:
            audio: Audio data (WAV format, 16kHz mono recommended)
            language: Optional language code (e.g., "en", "es"). None for auto-detect.

        Returns:
            Transcription result with text and metadata
        """
        self._load_model()

        # Save audio to temporary file (faster-whisper requires file path)
        with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_file:
            tmp_path = Path(tmp_file.name)
            tmp_file.write(audio)

        try:
            # Run transcription in thread pool to avoid blocking
            loop = asyncio.get_event_loop()
            segments, info = await loop.run_in_executor(
                None,
                lambda: self._model.transcribe(
                    str(tmp_path),
                    language=language,
                    beam_size=5,
                    vad_filter=True,  # Filter out non-speech
                    word_timestamps=True,  # Get word-level timestamps
                ),
            )

            # Collect all segments
            segment_list = []
            text_parts = []

            for segment in segments:
                segment_dict = {
                    "start": segment.start,
                    "end": segment.end,
                    "text": segment.text,
                    "confidence": getattr(segment, "avg_logprob", None),
                }

                # Add word-level timestamps if available
                if hasattr(segment, "words") and segment.words:
                    segment_dict["words"] = [
                        {
                            "word": word.word,
                            "start": word.start,
                            "end": word.end,
                            "probability": word.probability,
                        }
                        for word in segment.words
                    ]

                segment_list.append(segment_dict)
                text_parts.append(segment.text)

            # Combine all text
            full_text = " ".join(text_parts).strip()

            # Calculate average confidence
            confidences = [s["confidence"] for s in segment_list if s["confidence"] is not None]
            avg_confidence = sum(confidences) / len(confidences) if confidences else None

            return TranscriptionResult(
                text=full_text,
                language=info.language,
                confidence=avg_confidence,
                segments=segment_list,
            )

        finally:
            # Clean up temporary file
            try:
                tmp_path.unlink()
            except Exception as e:
                logger.warning(f"Failed to delete temporary file {tmp_path}: {e}")

    async def transcribe_stream(
        self,
        audio_stream: AsyncIterator[bytes],
    ) -> AsyncIterator[str]:
        """Stream transcription using VAD-based speech boundary detection.

        Uses Voice Activity Detection to segment audio into utterances and
        transcribes each utterance as soon as it ends. This reduces latency
        compared to fixed-size buffering and avoids wasting compute on silence.

        Falls back to time-based buffering (1.5s) if no speech boundaries
        are detected, ensuring partial results are still emitted.

        Args:
            audio_stream: Async iterator of audio chunks (16kHz, 16-bit mono)

        Yields:
            Transcription text for each detected utterance
        """
        self._load_model()

        from harombe.voice.vad import VADConfig, VoiceActivityDetector

        vad = VoiceActivityDetector(
            VADConfig(
                silence_duration_ms=600,
                min_speech_duration_ms=200,
            )
        )

        # Also keep a time-based fallback buffer
        buffer = io.BytesIO()
        fallback_bytes = 16000 * 2 * 2  # 2 seconds fallback if VAD doesn't trigger

        async for chunk in audio_stream:
            events = vad.process_frame(chunk)
            buffer.write(chunk)

            for event in events:
                if event.type == "speech_end" and event.audio:
                    # VAD detected end of utterance — transcribe it
                    result = await self.transcribe(event.audio)
                    if result.text:
                        yield result.text
                    buffer = io.BytesIO()

            # Fallback: if buffer gets large without a speech_end, transcribe anyway
            if buffer.tell() >= fallback_bytes:
                audio_data = buffer.getvalue()
                result = await self.transcribe(audio_data)
                if result.text:
                    yield result.text
                buffer = io.BytesIO()

        # Transcribe any remaining audio
        if buffer.tell() > 0:
            audio_data = buffer.getvalue()
            result = await self.transcribe(audio_data)
            if result.text:
                yield result.text

    @property
    def model_name(self) -> str:
        """Return the name of the STT model being used."""
        return f"whisper-{self._model_size}"

model_name property

Return the name of the STT model being used.

__init__(model_size='medium', device='auto', compute_type='float16', download_root=None)

Initialize Whisper STT engine.

Parameters:

Name Type Description Default
model_size Literal['tiny', 'base', 'small', 'medium', 'large-v3']

Size of Whisper model to use

'medium'
device Literal['cpu', 'cuda', 'auto']

Device to run inference on

'auto'
compute_type Literal['int8', 'float16', 'float32']

Compute type for inference (lower = faster but less accurate)

'float16'
download_root str | Path | None

Directory to download models to (default: ~/.cache/huggingface)

None
Source code in src/harombe/voice/whisper.py
def __init__(
    self,
    model_size: Literal["tiny", "base", "small", "medium", "large-v3"] = "medium",
    device: Literal["cpu", "cuda", "auto"] = "auto",
    compute_type: Literal["int8", "float16", "float32"] = "float16",
    download_root: str | Path | None = None,
):
    """Initialize Whisper STT engine.

    Args:
        model_size: Size of Whisper model to use
        device: Device to run inference on
        compute_type: Compute type for inference (lower = faster but less accurate)
        download_root: Directory to download models to (default: ~/.cache/huggingface)
    """
    self._model_size = model_size
    self._device = device
    self._compute_type = compute_type
    self._download_root = download_root
    self._model: Any = None

transcribe(audio, language=None) async

Transcribe audio to text.

Parameters:

Name Type Description Default
audio bytes

Audio data (WAV format, 16kHz mono recommended)

required
language str | None

Optional language code (e.g., "en", "es"). None for auto-detect.

None

Returns:

Type Description
TranscriptionResult

Transcription result with text and metadata

Source code in src/harombe/voice/whisper.py
async def transcribe(
    self,
    audio: bytes,
    language: str | None = None,
) -> TranscriptionResult:
    """Transcribe audio to text.

    Args:
        audio: Audio data (WAV format, 16kHz mono recommended)
        language: Optional language code (e.g., "en", "es"). None for auto-detect.

    Returns:
        Transcription result with text and metadata
    """
    self._load_model()

    # Save audio to temporary file (faster-whisper requires file path)
    with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_file:
        tmp_path = Path(tmp_file.name)
        tmp_file.write(audio)

    try:
        # Run transcription in thread pool to avoid blocking
        loop = asyncio.get_event_loop()
        segments, info = await loop.run_in_executor(
            None,
            lambda: self._model.transcribe(
                str(tmp_path),
                language=language,
                beam_size=5,
                vad_filter=True,  # Filter out non-speech
                word_timestamps=True,  # Get word-level timestamps
            ),
        )

        # Collect all segments
        segment_list = []
        text_parts = []

        for segment in segments:
            segment_dict = {
                "start": segment.start,
                "end": segment.end,
                "text": segment.text,
                "confidence": getattr(segment, "avg_logprob", None),
            }

            # Add word-level timestamps if available
            if hasattr(segment, "words") and segment.words:
                segment_dict["words"] = [
                    {
                        "word": word.word,
                        "start": word.start,
                        "end": word.end,
                        "probability": word.probability,
                    }
                    for word in segment.words
                ]

            segment_list.append(segment_dict)
            text_parts.append(segment.text)

        # Combine all text
        full_text = " ".join(text_parts).strip()

        # Calculate average confidence
        confidences = [s["confidence"] for s in segment_list if s["confidence"] is not None]
        avg_confidence = sum(confidences) / len(confidences) if confidences else None

        return TranscriptionResult(
            text=full_text,
            language=info.language,
            confidence=avg_confidence,
            segments=segment_list,
        )

    finally:
        # Clean up temporary file
        try:
            tmp_path.unlink()
        except Exception as e:
            logger.warning(f"Failed to delete temporary file {tmp_path}: {e}")

transcribe_stream(audio_stream) async

Stream transcription using VAD-based speech boundary detection.

Uses Voice Activity Detection to segment audio into utterances and transcribes each utterance as soon as it ends. This reduces latency compared to fixed-size buffering and avoids wasting compute on silence.

Falls back to time-based buffering (1.5s) if no speech boundaries are detected, ensuring partial results are still emitted.

Parameters:

Name Type Description Default
audio_stream AsyncIterator[bytes]

Async iterator of audio chunks (16kHz, 16-bit mono)

required

Yields:

Type Description
AsyncIterator[str]

Transcription text for each detected utterance

Source code in src/harombe/voice/whisper.py
async def transcribe_stream(
    self,
    audio_stream: AsyncIterator[bytes],
) -> AsyncIterator[str]:
    """Stream transcription using VAD-based speech boundary detection.

    Uses Voice Activity Detection to segment audio into utterances and
    transcribes each utterance as soon as it ends. This reduces latency
    compared to fixed-size buffering and avoids wasting compute on silence.

    Falls back to time-based buffering (1.5s) if no speech boundaries
    are detected, ensuring partial results are still emitted.

    Args:
        audio_stream: Async iterator of audio chunks (16kHz, 16-bit mono)

    Yields:
        Transcription text for each detected utterance
    """
    self._load_model()

    from harombe.voice.vad import VADConfig, VoiceActivityDetector

    vad = VoiceActivityDetector(
        VADConfig(
            silence_duration_ms=600,
            min_speech_duration_ms=200,
        )
    )

    # Also keep a time-based fallback buffer
    buffer = io.BytesIO()
    fallback_bytes = 16000 * 2 * 2  # 2 seconds fallback if VAD doesn't trigger

    async for chunk in audio_stream:
        events = vad.process_frame(chunk)
        buffer.write(chunk)

        for event in events:
            if event.type == "speech_end" and event.audio:
                # VAD detected end of utterance — transcribe it
                result = await self.transcribe(event.audio)
                if result.text:
                    yield result.text
                buffer = io.BytesIO()

        # Fallback: if buffer gets large without a speech_end, transcribe anyway
        if buffer.tell() >= fallback_bytes:
            audio_data = buffer.getvalue()
            result = await self.transcribe(audio_data)
            if result.text:
                yield result.text
            buffer = io.BytesIO()

    # Transcribe any remaining audio
    if buffer.tell() > 0:
        audio_data = buffer.getvalue()
        result = await self.transcribe(audio_data)
        if result.text:
            yield result.text

create_coqui_tts(model_name='tts_models/en/ljspeech/tacotron2-DDC', device='cpu')

Factory function to create a Coqui TTS engine.

Parameters:

Name Type Description Default
model_name str

Coqui TTS model path

'tts_models/en/ljspeech/tacotron2-DDC'
device str

Device to run on ("cpu" or "cuda")

'cpu'

Returns:

Type Description
CoquiTTS

Configured CoquiTTS instance

Source code in src/harombe/voice/coqui.py
def create_coqui_tts(
    model_name: str = "tts_models/en/ljspeech/tacotron2-DDC",
    device: str = "cpu",
) -> CoquiTTS:
    """Factory function to create a Coqui TTS engine.

    Args:
        model_name: Coqui TTS model path
        device: Device to run on ("cpu" or "cuda")

    Returns:
        Configured CoquiTTS instance

    Popular Coqui models:
        - tts_models/en/ljspeech/tacotron2-DDC (default, good quality)
        - tts_models/en/vctk/vits (multi-speaker, high quality)
        - tts_models/en/ljspeech/fast_pitch (fast, good quality)
    """
    return CoquiTTS(
        model_name=model_name,
        device=device,  # type: ignore[arg-type]
    )

create_piper_tts(model='en_US-lessac-medium', device='cpu')

Factory function to create a Piper TTS engine.

Parameters:

Name Type Description Default
model str

Piper model name

'en_US-lessac-medium'
device str

Device to run on ("cpu" or "cuda")

'cpu'

Returns:

Type Description
PiperTTS

Configured PiperTTS instance

Source code in src/harombe/voice/piper.py
def create_piper_tts(
    model: str = "en_US-lessac-medium",
    device: str = "cpu",
) -> PiperTTS:
    """Factory function to create a Piper TTS engine.

    Args:
        model: Piper model name
        device: Device to run on ("cpu" or "cuda")

    Returns:
        Configured PiperTTS instance

    Popular Piper models:
        - en_US-lessac-medium (default, good quality, fast)
        - en_US-lessac-high (better quality, slower)
        - en_US-amy-medium (female voice)
        - en_GB-alan-medium (British accent)
    """
    return PiperTTS(
        model=model,
        device=device,  # type: ignore[arg-type]
    )

create_whisper_stt(model_size='medium', device='auto', compute_type='float16')

Factory function to create a Whisper STT engine.

Parameters:

Name Type Description Default
model_size str

Size of Whisper model ("tiny", "base", "small", "medium", "large-v3")

'medium'
device str

Device to run on ("cpu", "cuda", "auto")

'auto'
compute_type str

Compute type ("int8", "float16", "float32")

'float16'

Returns:

Type Description
WhisperSTT

Configured WhisperSTT instance

Source code in src/harombe/voice/whisper.py
def create_whisper_stt(
    model_size: str = "medium",
    device: str = "auto",
    compute_type: str = "float16",
) -> WhisperSTT:
    """Factory function to create a Whisper STT engine.

    Args:
        model_size: Size of Whisper model ("tiny", "base", "small", "medium", "large-v3")
        device: Device to run on ("cpu", "cuda", "auto")
        compute_type: Compute type ("int8", "float16", "float32")

    Returns:
        Configured WhisperSTT instance
    """
    return WhisperSTT(
        model_size=model_size,  # type: ignore[arg-type]
        device=device,  # type: ignore[arg-type]
        compute_type=compute_type,  # type: ignore[arg-type]
    )

options: show_root_heading: true members_order: source

LLM Clients

LLM backend abstraction: Ollama, Anthropic, remote nodes.

harombe.llm

LLM client implementations.

AnthropicClient

LLM client for the Anthropic Messages API.

Source code in src/harombe/llm/anthropic.py
class AnthropicClient:
    """LLM client for the Anthropic Messages API."""

    def __init__(
        self,
        api_key: str,
        model: str = "claude-sonnet-4-20250514",
        max_tokens: int = 4096,
        timeout: int = 120,
        temperature: float = 0.7,
    ):
        """Initialize Anthropic client.

        Args:
            api_key: Anthropic API key
            model: Model name (e.g., "claude-sonnet-4-20250514")
            max_tokens: Default max tokens for responses
            timeout: Request timeout in seconds
            temperature: Default sampling temperature
        """
        self.model = model
        self.max_tokens = max_tokens
        self.temperature = temperature

        self.client = httpx.AsyncClient(
            base_url="https://api.anthropic.com",
            headers={
                "x-api-key": api_key,
                "anthropic-version": ANTHROPIC_API_VERSION,
                "content-type": "application/json",
            },
            timeout=httpx.Timeout(timeout),
        )

    def _convert_messages(self, messages: list[Message]) -> tuple[str | None, list[dict[str, Any]]]:
        """Convert internal Message format to Anthropic format.

        Anthropic requires the system message to be separate from the
        messages array, so we extract it.

        Args:
            messages: List of Message objects

        Returns:
            Tuple of (system_prompt, anthropic_messages)
        """
        system_prompt = None
        anthropic_messages: list[dict[str, Any]] = []

        for msg in messages:
            if msg.role == "system":
                system_prompt = msg.content
                continue

            if msg.role == "assistant" and msg.tool_calls:
                # Anthropic uses content blocks for tool use
                content_blocks: list[dict[str, Any]] = []
                if msg.content:
                    content_blocks.append({"type": "text", "text": msg.content})
                for tc in msg.tool_calls:
                    content_blocks.append(
                        {
                            "type": "tool_use",
                            "id": tc.id,
                            "name": tc.name,
                            "input": tc.arguments,
                        }
                    )
                anthropic_messages.append(
                    {
                        "role": "assistant",
                        "content": content_blocks,
                    }
                )

            elif msg.role == "tool":
                # Anthropic uses tool_result content blocks
                anthropic_messages.append(
                    {
                        "role": "user",
                        "content": [
                            {
                                "type": "tool_result",
                                "tool_use_id": msg.tool_call_id,
                                "content": msg.content,
                            }
                        ],
                    }
                )

            else:
                anthropic_messages.append(
                    {
                        "role": msg.role,
                        "content": msg.content,
                    }
                )

        return system_prompt, anthropic_messages

    def _convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
        """Convert OpenAI function format to Anthropic tool format.

        Args:
            tools: Tools in OpenAI format

        Returns:
            Tools in Anthropic format
        """
        anthropic_tools = []
        for tool in tools:
            func = tool.get("function", tool)
            anthropic_tools.append(
                {
                    "name": func["name"],
                    "description": func.get("description", ""),
                    "input_schema": func.get("parameters", {"type": "object", "properties": {}}),
                }
            )
        return anthropic_tools

    def _parse_tool_calls(self, content_blocks: list[dict[str, Any]]) -> tuple[str, list[ToolCall]]:
        """Parse Anthropic response content blocks into text + tool calls.

        Args:
            content_blocks: Anthropic response content blocks

        Returns:
            Tuple of (text_content, tool_calls)
        """
        text_parts: list[str] = []
        tool_calls: list[ToolCall] = []

        for block in content_blocks:
            if block["type"] == "text":
                text_parts.append(block["text"])
            elif block["type"] == "tool_use":
                tool_calls.append(
                    ToolCall(
                        id=block["id"],
                        name=block["name"],
                        arguments=block["input"],
                    )
                )

        return "\n".join(text_parts), tool_calls

    async def complete(
        self,
        messages: list[Message],
        tools: list[dict[str, Any]] | None = None,
        temperature: float | None = None,
        max_tokens: int | None = None,
    ) -> CompletionResponse:
        """Generate a completion from Anthropic Claude.

        Args:
            messages: Conversation history
            tools: Available tools in OpenAI function format
            temperature: Sampling temperature override
            max_tokens: Maximum tokens to generate

        Returns:
            CompletionResponse with content and optional tool calls
        """
        system_prompt, anthropic_messages = self._convert_messages(messages)

        payload: dict[str, Any] = {
            "model": self.model,
            "messages": anthropic_messages,
            "max_tokens": max_tokens or self.max_tokens,
            "temperature": temperature if temperature is not None else self.temperature,
        }

        if system_prompt:
            payload["system"] = system_prompt

        if tools:
            payload["tools"] = self._convert_tools(tools)

        response = await self.client.post("/v1/messages", json=payload)
        response.raise_for_status()
        data = response.json()

        content_text, tool_calls = self._parse_tool_calls(data["content"])

        stop_reason = data.get("stop_reason", "end_turn")
        finish_reason = "tool_calls" if stop_reason == "tool_use" else "stop"

        return CompletionResponse(
            content=content_text,
            tool_calls=tool_calls if tool_calls else None,
            finish_reason=finish_reason,
        )

    async def stream_complete(
        self,
        messages: list[Message],
        tools: list[dict[str, Any]] | None = None,
        temperature: float | None = None,
    ) -> AsyncIterator[str]:
        """Stream a completion from Anthropic Claude.

        Args:
            messages: Conversation history
            tools: Available tools in OpenAI function format
            temperature: Sampling temperature override

        Yields:
            Content chunks as strings
        """
        system_prompt, anthropic_messages = self._convert_messages(messages)

        payload: dict[str, Any] = {
            "model": self.model,
            "messages": anthropic_messages,
            "max_tokens": self.max_tokens,
            "temperature": temperature if temperature is not None else self.temperature,
            "stream": True,
        }

        if system_prompt:
            payload["system"] = system_prompt

        if tools:
            payload["tools"] = self._convert_tools(tools)

        async with self.client.stream("POST", "/v1/messages", json=payload) as response:
            response.raise_for_status()
            async for line in response.aiter_lines():
                if not line.startswith("data: "):
                    continue
                data = json.loads(line[6:])
                if data["type"] == "content_block_delta":
                    delta = data.get("delta", {})
                    if delta.get("type") == "text_delta":
                        yield delta["text"]

    async def close(self) -> None:
        """Close the underlying HTTP client."""
        await self.client.aclose()

__init__(api_key, model='claude-sonnet-4-20250514', max_tokens=4096, timeout=120, temperature=0.7)

Initialize Anthropic client.

Parameters:

Name Type Description Default
api_key str

Anthropic API key

required
model str

Model name (e.g., "claude-sonnet-4-20250514")

'claude-sonnet-4-20250514'
max_tokens int

Default max tokens for responses

4096
timeout int

Request timeout in seconds

120
temperature float

Default sampling temperature

0.7
Source code in src/harombe/llm/anthropic.py
def __init__(
    self,
    api_key: str,
    model: str = "claude-sonnet-4-20250514",
    max_tokens: int = 4096,
    timeout: int = 120,
    temperature: float = 0.7,
):
    """Initialize Anthropic client.

    Args:
        api_key: Anthropic API key
        model: Model name (e.g., "claude-sonnet-4-20250514")
        max_tokens: Default max tokens for responses
        timeout: Request timeout in seconds
        temperature: Default sampling temperature
    """
    self.model = model
    self.max_tokens = max_tokens
    self.temperature = temperature

    self.client = httpx.AsyncClient(
        base_url="https://api.anthropic.com",
        headers={
            "x-api-key": api_key,
            "anthropic-version": ANTHROPIC_API_VERSION,
            "content-type": "application/json",
        },
        timeout=httpx.Timeout(timeout),
    )

complete(messages, tools=None, temperature=None, max_tokens=None) async

Generate a completion from Anthropic Claude.

Parameters:

Name Type Description Default
messages list[Message]

Conversation history

required
tools list[dict[str, Any]] | None

Available tools in OpenAI function format

None
temperature float | None

Sampling temperature override

None
max_tokens int | None

Maximum tokens to generate

None

Returns:

Type Description
CompletionResponse

CompletionResponse with content and optional tool calls

Source code in src/harombe/llm/anthropic.py
async def complete(
    self,
    messages: list[Message],
    tools: list[dict[str, Any]] | None = None,
    temperature: float | None = None,
    max_tokens: int | None = None,
) -> CompletionResponse:
    """Generate a completion from Anthropic Claude.

    Args:
        messages: Conversation history
        tools: Available tools in OpenAI function format
        temperature: Sampling temperature override
        max_tokens: Maximum tokens to generate

    Returns:
        CompletionResponse with content and optional tool calls
    """
    system_prompt, anthropic_messages = self._convert_messages(messages)

    payload: dict[str, Any] = {
        "model": self.model,
        "messages": anthropic_messages,
        "max_tokens": max_tokens or self.max_tokens,
        "temperature": temperature if temperature is not None else self.temperature,
    }

    if system_prompt:
        payload["system"] = system_prompt

    if tools:
        payload["tools"] = self._convert_tools(tools)

    response = await self.client.post("/v1/messages", json=payload)
    response.raise_for_status()
    data = response.json()

    content_text, tool_calls = self._parse_tool_calls(data["content"])

    stop_reason = data.get("stop_reason", "end_turn")
    finish_reason = "tool_calls" if stop_reason == "tool_use" else "stop"

    return CompletionResponse(
        content=content_text,
        tool_calls=tool_calls if tool_calls else None,
        finish_reason=finish_reason,
    )

stream_complete(messages, tools=None, temperature=None) async

Stream a completion from Anthropic Claude.

Parameters:

Name Type Description Default
messages list[Message]

Conversation history

required
tools list[dict[str, Any]] | None

Available tools in OpenAI function format

None
temperature float | None

Sampling temperature override

None

Yields:

Type Description
AsyncIterator[str]

Content chunks as strings

Source code in src/harombe/llm/anthropic.py
async def stream_complete(
    self,
    messages: list[Message],
    tools: list[dict[str, Any]] | None = None,
    temperature: float | None = None,
) -> AsyncIterator[str]:
    """Stream a completion from Anthropic Claude.

    Args:
        messages: Conversation history
        tools: Available tools in OpenAI function format
        temperature: Sampling temperature override

    Yields:
        Content chunks as strings
    """
    system_prompt, anthropic_messages = self._convert_messages(messages)

    payload: dict[str, Any] = {
        "model": self.model,
        "messages": anthropic_messages,
        "max_tokens": self.max_tokens,
        "temperature": temperature if temperature is not None else self.temperature,
        "stream": True,
    }

    if system_prompt:
        payload["system"] = system_prompt

    if tools:
        payload["tools"] = self._convert_tools(tools)

    async with self.client.stream("POST", "/v1/messages", json=payload) as response:
        response.raise_for_status()
        async for line in response.aiter_lines():
            if not line.startswith("data: "):
                continue
            data = json.loads(line[6:])
            if data["type"] == "content_block_delta":
                delta = data.get("delta", {})
                if delta.get("type") == "text_delta":
                    yield delta["text"]

close() async

Close the underlying HTTP client.

Source code in src/harombe/llm/anthropic.py
async def close(self) -> None:
    """Close the underlying HTTP client."""
    await self.client.aclose()

CompletionResponse dataclass

Response from LLM completion.

Source code in src/harombe/llm/client.py
@dataclass
class CompletionResponse:
    """Response from LLM completion."""

    content: str
    tool_calls: list[ToolCall] | None = None
    finish_reason: str = "stop"

LLMClient

Bases: Protocol

Protocol for LLM client implementations.

Source code in src/harombe/llm/client.py
class LLMClient(Protocol):
    """Protocol for LLM client implementations."""

    async def complete(
        self,
        messages: list[Message],
        tools: list[dict[str, Any]] | None = None,
        temperature: float | None = None,
        max_tokens: int | None = None,
    ) -> CompletionResponse:
        """Generate a completion from the LLM.

        Args:
            messages: Conversation history
            tools: Available tools in OpenAI function format
            temperature: Sampling temperature override
            max_tokens: Maximum tokens to generate

        Returns:
            CompletionResponse with content and optional tool calls
        """
        ...

    async def stream_complete(
        self,
        messages: list[Message],
        tools: list[dict[str, Any]] | None = None,
        temperature: float | None = None,
    ) -> Any:
        """Stream a completion from the LLM.

        Args:
            messages: Conversation history
            tools: Available tools in OpenAI function format
            temperature: Sampling temperature override

        Yields:
            Content chunks as they arrive
        """
        ...

complete(messages, tools=None, temperature=None, max_tokens=None) async

Generate a completion from the LLM.

Parameters:

Name Type Description Default
messages list[Message]

Conversation history

required
tools list[dict[str, Any]] | None

Available tools in OpenAI function format

None
temperature float | None

Sampling temperature override

None
max_tokens int | None

Maximum tokens to generate

None

Returns:

Type Description
CompletionResponse

CompletionResponse with content and optional tool calls

Source code in src/harombe/llm/client.py
async def complete(
    self,
    messages: list[Message],
    tools: list[dict[str, Any]] | None = None,
    temperature: float | None = None,
    max_tokens: int | None = None,
) -> CompletionResponse:
    """Generate a completion from the LLM.

    Args:
        messages: Conversation history
        tools: Available tools in OpenAI function format
        temperature: Sampling temperature override
        max_tokens: Maximum tokens to generate

    Returns:
        CompletionResponse with content and optional tool calls
    """
    ...

stream_complete(messages, tools=None, temperature=None) async

Stream a completion from the LLM.

Parameters:

Name Type Description Default
messages list[Message]

Conversation history

required
tools list[dict[str, Any]] | None

Available tools in OpenAI function format

None
temperature float | None

Sampling temperature override

None

Yields:

Type Description
Any

Content chunks as they arrive

Source code in src/harombe/llm/client.py
async def stream_complete(
    self,
    messages: list[Message],
    tools: list[dict[str, Any]] | None = None,
    temperature: float | None = None,
) -> Any:
    """Stream a completion from the LLM.

    Args:
        messages: Conversation history
        tools: Available tools in OpenAI function format
        temperature: Sampling temperature override

    Yields:
        Content chunks as they arrive
    """
    ...

Message dataclass

A message in the conversation.

Source code in src/harombe/llm/client.py
@dataclass
class Message:
    """A message in the conversation."""

    role: str  # "system", "user", "assistant", "tool"
    content: str
    tool_calls: list["ToolCall"] | None = None
    tool_call_id: str | None = None  # For tool response messages
    name: str | None = None  # Tool name for tool response messages

ToolCall dataclass

A tool call requested by the LLM.

Source code in src/harombe/llm/client.py
@dataclass
class ToolCall:
    """A tool call requested by the LLM."""

    id: str
    name: str
    arguments: dict[str, Any]

OllamaClient

LLM client that wraps Ollama's OpenAI-compatible API.

Source code in src/harombe/llm/ollama.py
class OllamaClient:
    """LLM client that wraps Ollama's OpenAI-compatible API."""

    def __init__(
        self,
        model: str,
        base_url: str = "http://localhost:11434/v1",
        timeout: int = 120,
        temperature: float = 0.7,
    ):
        """Initialize Ollama client.

        Args:
            model: Model name (e.g., "qwen2.5:7b")
            base_url: Ollama OpenAI-compatible endpoint
            timeout: Request timeout in seconds
            temperature: Default sampling temperature
        """
        self.model = model
        self.temperature = temperature

        # OpenAI SDK pointed at Ollama
        self.client = AsyncOpenAI(
            base_url=base_url,
            api_key="ollama",  # Ollama doesn't use API keys but SDK requires one
            timeout=timeout,
        )

    def _convert_messages(self, messages: list[Message]) -> list[dict[str, Any]]:
        """Convert internal Message format to OpenAI format.

        Args:
            messages: List of Message objects

        Returns:
            List of message dicts in OpenAI format
        """
        openai_messages = []

        for msg in messages:
            message_dict: dict[str, Any] = {
                "role": msg.role,
                "content": msg.content,
            }

            # Add tool calls if present
            if msg.tool_calls:
                message_dict["tool_calls"] = [
                    {
                        "id": tc.id,
                        "type": "function",
                        "function": {
                            "name": tc.name,
                            "arguments": json.dumps(tc.arguments),
                        },
                    }
                    for tc in msg.tool_calls
                ]

            # Add tool call metadata for tool response messages
            if msg.tool_call_id:
                message_dict["tool_call_id"] = msg.tool_call_id
            if msg.name:
                message_dict["name"] = msg.name

            openai_messages.append(message_dict)

        return openai_messages

    def _parse_tool_calls(self, tool_calls: Any) -> list[ToolCall]:
        """Parse tool calls from OpenAI response.

        Args:
            tool_calls: Raw tool calls from OpenAI response

        Returns:
            List of ToolCall objects
        """
        if not tool_calls:
            return []

        parsed = []
        for tc in tool_calls:
            # Parse arguments JSON string
            args = json.loads(tc.function.arguments)

            parsed.append(
                ToolCall(
                    id=tc.id,
                    name=tc.function.name,
                    arguments=args,
                )
            )

        return parsed

    async def complete(
        self,
        messages: list[Message],
        tools: list[dict[str, Any]] | None = None,
        temperature: float | None = None,
        max_tokens: int | None = None,
    ) -> CompletionResponse:
        """Generate a completion from Ollama.

        Args:
            messages: Conversation history
            tools: Available tools in OpenAI function format
            temperature: Sampling temperature override
            max_tokens: Maximum tokens to generate

        Returns:
            CompletionResponse with content and optional tool calls
        """
        openai_messages = self._convert_messages(messages)

        params: dict[str, Any] = {
            "model": self.model,
            "messages": openai_messages,
            "temperature": temperature if temperature is not None else self.temperature,
        }

        if tools:
            params["tools"] = tools
            params["tool_choice"] = "auto"

        if max_tokens:
            params["max_tokens"] = max_tokens

        response = await self.client.chat.completions.create(**params)

        choice = response.choices[0]
        message = choice.message

        # Parse tool calls if present
        tool_calls = self._parse_tool_calls(message.tool_calls)

        return CompletionResponse(
            content=message.content or "",
            tool_calls=tool_calls if tool_calls else None,
            finish_reason=choice.finish_reason or "stop",
        )

    async def stream_complete(
        self,
        messages: list[Message],
        tools: list[dict[str, Any]] | None = None,
        temperature: float | None = None,
    ) -> AsyncIterator[str]:
        """Stream a completion from Ollama.

        Args:
            messages: Conversation history
            tools: Available tools in OpenAI function format
            temperature: Sampling temperature override

        Yields:
            Content chunks as strings
        """
        openai_messages = self._convert_messages(messages)

        params: dict[str, Any] = {
            "model": self.model,
            "messages": openai_messages,
            "temperature": temperature if temperature is not None else self.temperature,
            "stream": True,
        }

        if tools:
            params["tools"] = tools
            params["tool_choice"] = "auto"

        stream = await self.client.chat.completions.create(**params)

        async for chunk in stream:
            if chunk.choices and chunk.choices[0].delta.content:
                yield chunk.choices[0].delta.content

__init__(model, base_url='http://localhost:11434/v1', timeout=120, temperature=0.7)

Initialize Ollama client.

Parameters:

Name Type Description Default
model str

Model name (e.g., "qwen2.5:7b")

required
base_url str

Ollama OpenAI-compatible endpoint

'http://localhost:11434/v1'
timeout int

Request timeout in seconds

120
temperature float

Default sampling temperature

0.7
Source code in src/harombe/llm/ollama.py
def __init__(
    self,
    model: str,
    base_url: str = "http://localhost:11434/v1",
    timeout: int = 120,
    temperature: float = 0.7,
):
    """Initialize Ollama client.

    Args:
        model: Model name (e.g., "qwen2.5:7b")
        base_url: Ollama OpenAI-compatible endpoint
        timeout: Request timeout in seconds
        temperature: Default sampling temperature
    """
    self.model = model
    self.temperature = temperature

    # OpenAI SDK pointed at Ollama
    self.client = AsyncOpenAI(
        base_url=base_url,
        api_key="ollama",  # Ollama doesn't use API keys but SDK requires one
        timeout=timeout,
    )

complete(messages, tools=None, temperature=None, max_tokens=None) async

Generate a completion from Ollama.

Parameters:

Name Type Description Default
messages list[Message]

Conversation history

required
tools list[dict[str, Any]] | None

Available tools in OpenAI function format

None
temperature float | None

Sampling temperature override

None
max_tokens int | None

Maximum tokens to generate

None

Returns:

Type Description
CompletionResponse

CompletionResponse with content and optional tool calls

Source code in src/harombe/llm/ollama.py
async def complete(
    self,
    messages: list[Message],
    tools: list[dict[str, Any]] | None = None,
    temperature: float | None = None,
    max_tokens: int | None = None,
) -> CompletionResponse:
    """Generate a completion from Ollama.

    Args:
        messages: Conversation history
        tools: Available tools in OpenAI function format
        temperature: Sampling temperature override
        max_tokens: Maximum tokens to generate

    Returns:
        CompletionResponse with content and optional tool calls
    """
    openai_messages = self._convert_messages(messages)

    params: dict[str, Any] = {
        "model": self.model,
        "messages": openai_messages,
        "temperature": temperature if temperature is not None else self.temperature,
    }

    if tools:
        params["tools"] = tools
        params["tool_choice"] = "auto"

    if max_tokens:
        params["max_tokens"] = max_tokens

    response = await self.client.chat.completions.create(**params)

    choice = response.choices[0]
    message = choice.message

    # Parse tool calls if present
    tool_calls = self._parse_tool_calls(message.tool_calls)

    return CompletionResponse(
        content=message.content or "",
        tool_calls=tool_calls if tool_calls else None,
        finish_reason=choice.finish_reason or "stop",
    )

stream_complete(messages, tools=None, temperature=None) async

Stream a completion from Ollama.

Parameters:

Name Type Description Default
messages list[Message]

Conversation history

required
tools list[dict[str, Any]] | None

Available tools in OpenAI function format

None
temperature float | None

Sampling temperature override

None

Yields:

Type Description
AsyncIterator[str]

Content chunks as strings

Source code in src/harombe/llm/ollama.py
async def stream_complete(
    self,
    messages: list[Message],
    tools: list[dict[str, Any]] | None = None,
    temperature: float | None = None,
) -> AsyncIterator[str]:
    """Stream a completion from Ollama.

    Args:
        messages: Conversation history
        tools: Available tools in OpenAI function format
        temperature: Sampling temperature override

    Yields:
        Content chunks as strings
    """
    openai_messages = self._convert_messages(messages)

    params: dict[str, Any] = {
        "model": self.model,
        "messages": openai_messages,
        "temperature": temperature if temperature is not None else self.temperature,
        "stream": True,
    }

    if tools:
        params["tools"] = tools
        params["tool_choice"] = "auto"

    stream = await self.client.chat.completions.create(**params)

    async for chunk in stream:
        if chunk.choices and chunk.choices[0].delta.content:
            yield chunk.choices[0].delta.content

options: show_root_heading: true members_order: source